heartbit-core 2026.613.1

The Rust agentic framework — agents, tools, LLM providers, memory, evaluation.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
//! [`GoalCondition`] — a persistent objective that keeps an agent working across
//! turns until an **independent** judge confirms the objective is met.
//!
//! This is heartbit's equivalent of Claude Code's `/goal`: a completion
//! condition evaluated after the agent would naturally finish a turn. A small,
//! impartial judge (a *separate* provider call — never the working agent grading
//! itself) reads the objective plus what the agent has surfaced and returns
//! met / not-met + a short reason. Not-met re-injects the reason as guidance and
//! the agent continues; met (or the continuation cap is reached) ends the run.
//!
//! ## Why an independent judge (not self-assessment)
//!
//! Agents systematically OVER-REPORT success — an agent asked "are you done?"
//! tends to say yes ("An Illusion of Progress?", arXiv:2504.01382; their WebJudge
//! shows an *independent* judge over the final state agrees with humans ~85% vs.
//! inflated self-reports). And an LLM grading its own output is biased toward it
//! ("Self-Preference Bias in LLM-as-a-Judge", arXiv:2410.21819 — persists even
//! when authorship is hidden). So the judge is a distinct provider call with an
//! impartial-evaluator prompt and no tools, mirroring `/goal`'s separate fast
//! model that "judges only what the agent has surfaced".
//!
//! ## Termination
//!
//! Bounded on both sides: the judge gates the natural-completion exit (no
//! premature stop) while `max_continuations` + the agent's own `max_turns` bound
//! the loop (no infinite loop). On an unparseable/empty judge reply the verdict
//! is treated as NOT met (keep working) — the safe direction against
//! over-reporting — still bounded by the cap.

use std::sync::Arc;

use crate::llm::types::{CompletionRequest, ContentBlock, Message, TokenUsage};
use crate::llm::{BoxedProvider, LlmProvider};

/// Default number of extra continuations granted to reach the goal after the
/// agent first naturally completes.
pub const DEFAULT_MAX_CONTINUATIONS: u32 = 8;

/// Max tokens for a judge reply — the verdict is one short line plus a reason.
const JUDGE_MAX_TOKENS: u32 = 256;

/// Cap on the transcript (tail) shown to the judge, in characters. The judge
/// must see the EVIDENCE (recent tool results), not just the agent's claim, but
/// an unbounded transcript would blow its context — so we keep the most recent
/// tail, where the demonstrating tool output and final answer live.
const MAX_TRANSCRIPT_CHARS: usize = 12_000;

/// Impartial-evaluator system prompt for the goal judge. It deliberately frames
/// the judge as a skeptical external verifier (anti over-report) that decides
/// only from the evidence the agent surfaced.
const JUDGE_SYSTEM_PROMPT: &str = "\
You are an impartial completion judge. You did NOT do the work; you only verify \
it. Given an OBJECTIVE and the WORKING TRANSCRIPT/OUTPUT an agent has produced, \
decide whether the objective is genuinely and verifiably satisfied by the \
evidence shown — not merely claimed. An agent asserting it is done is NOT \
evidence; look for the concrete result the objective requires (e.g. a passing \
test, an exit code, the requested content). Be skeptical: if the evidence is \
absent, ambiguous, or only asserted, the objective is NOT met.\n\n\
Respond with EXACTLY one verdict line, then optionally a brief reason:\n\
  GOAL_MET: YES\n\
or\n\
  GOAL_MET: NO: <one sentence on what concrete evidence is still missing>";

/// Shared, runtime-mutable goal slot: the runner reads it at every natural
/// stop; the `set_goal` tool (entry agent) installs/replaces the goal
/// mid-run — e.g. with the acceptance criteria the `intake` recipe produced.
/// `std::sync::RwLock` per repo convention (never held across an await; the
/// goal is CLONED out before the async judge call).
pub type GoalSlot = Arc<std::sync::RwLock<Option<GoalCondition>>>;

/// A persistent objective evaluated by an independent judge after each
/// natural completion.
#[derive(Clone)]
pub struct GoalCondition {
    objective: String,
    judge: Arc<BoxedProvider>,
    max_continuations: u32,
    /// When true, the judge grades EACH criterion line of the objective
    /// independently (checklist verdict). Off by default — it lengthens the
    /// judge reply and is only worth the cost for multi-criterion objectives.
    per_criterion: bool,
}

/// The judge's decision plus its reason (the reason becomes the agent's
/// next-turn guidance when the goal is not yet met).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GoalVerdict {
    /// Whether the objective is satisfied by the surfaced evidence.
    pub satisfied: bool,
    /// Short reason — what remains, when not satisfied.
    pub reason: String,
}

impl GoalCondition {
    /// Create a goal from an `objective` string and an INDEPENDENT judge
    /// provider (a separate provider/model from the working agent). Uses
    /// [`DEFAULT_MAX_CONTINUATIONS`].
    pub fn new(objective: impl Into<String>, judge: Arc<BoxedProvider>) -> Self {
        Self {
            objective: objective.into(),
            judge,
            max_continuations: DEFAULT_MAX_CONTINUATIONS,
            per_criterion: false,
        }
    }

    /// Opt into per-criterion judging: the judge grades each criterion line of
    /// the objective separately and names the specific unmet ones — sharper
    /// continuation guidance for multi-criterion objectives, at the cost of a
    /// longer judge reply.
    pub fn with_per_criterion(mut self, on: bool) -> Self {
        self.per_criterion = on;
        self
    }

    /// Override how many extra continuations are granted to reach the goal after
    /// the agent first naturally completes. `0` means "judge once, never
    /// continue" (the judge still gates the exit, but no re-prompt is issued).
    pub fn with_max_continuations(mut self, n: u32) -> Self {
        self.max_continuations = n;
        self
    }

    /// The configured continuation cap.
    pub fn max_continuations(&self) -> u32 {
        self.max_continuations
    }

    /// The objective text (used to recite the goal in continuation guidance).
    pub fn objective(&self) -> &str {
        &self.objective
    }

    /// Whether per-criterion judging is enabled (see [`Self::with_per_criterion`]).
    pub fn per_criterion(&self) -> bool {
        self.per_criterion
    }

    /// Build the guidance message injected when the goal is not yet met: the
    /// judge's reason plus a recitation of the objective (goal-recitation keeps
    /// the long-horizon objective in context).
    pub(crate) fn continuation_message(&self, reason: &str) -> String {
        format!(
            "The objective is not yet complete. {reason}\n\nContinue working toward this \
             objective and demonstrate the concrete result it requires: {}",
            self.objective
        )
    }

    /// Ask the independent judge whether the objective is met. `transcript` is
    /// the working conversation rendered to text (including tool results — the
    /// EVIDENCE), of which the most recent [`MAX_TRANSCRIPT_CHARS`] are shown so
    /// the judge grades the demonstrated result, not merely the agent's claim.
    ///
    /// Returns the verdict plus the judge call's token usage (so the caller can
    /// account it against the run's budget). A judge or network error fails
    /// toward NOT-met (keep working) so a flaky judge never prematurely declares
    /// done — bounded by the continuation cap.
    pub(crate) async fn evaluate(&self, transcript: &str) -> (GoalVerdict, TokenUsage) {
        let evidence = tail_chars(transcript, MAX_TRANSCRIPT_CHARS);
        let user = format!(
            "OBJECTIVE:\n{}\n\nWORKING TRANSCRIPT (most recent; tool results are the \
             evidence — `[Tool result: ...]` lines show what actually happened):\n{}\n\n\
             Is the objective satisfied by the evidence above? Reply with the verdict line.",
            self.objective, evidence
        );
        let system = if self.per_criterion {
            format!(
                "{JUDGE_SYSTEM_PROMPT}\n\nThe OBJECTIVE is a list of acceptance criteria. \
                 Evaluate EACH criterion independently against the evidence: output one line \
                 per criterion, `- [met] <criterion>` or `- [NOT MET] <criterion>: <missing \
                 evidence>`, BEFORE the verdict line. GOAL_MET: YES only when EVERY criterion \
                 is met; otherwise GOAL_MET: NO: name the specific unmet criteria."
            )
        } else {
            JUDGE_SYSTEM_PROMPT.to_string()
        };
        let max_tokens = if self.per_criterion {
            // One checklist line per criterion needs more room than a bare verdict.
            JUDGE_MAX_TOKENS * 4
        } else {
            JUDGE_MAX_TOKENS
        };
        let request = CompletionRequest {
            system,
            messages: vec![Message::user(user)],
            tools: Vec::new(),
            max_tokens,
            tool_choice: None,
            reasoning_effort: None,
        };
        match self.judge.complete(request).await {
            Ok(response) => (
                parse_goal_verdict(&response_text(&response.content)),
                response.usage,
            ),
            Err(e) => {
                tracing::warn!(error = %e, "goal judge call failed; treating goal as not-met");
                (
                    GoalVerdict {
                        satisfied: false,
                        reason: "judge unavailable; continuing".to_string(),
                    },
                    TokenUsage::default(),
                )
            }
        }
    }
}

/// Keep the last `max` characters of `s` (on a char boundary), prefixed with an
/// elision marker when truncated. The tail holds the most recent tool results
/// and the final answer — the judge's evidence.
fn tail_chars(s: &str, max: usize) -> String {
    if s.chars().count() <= max {
        return s.to_string();
    }
    let skip = s.chars().count() - max;
    let tail: String = s.chars().skip(skip).collect();
    format!("[... earlier turns omitted ...]\n{tail}")
}

impl std::fmt::Debug for GoalCondition {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("GoalCondition")
            .field("objective", &self.objective)
            .field("max_continuations", &self.max_continuations)
            .finish_non_exhaustive()
    }
}

/// Concatenate the text content blocks of a completion response.
fn response_text(content: &[ContentBlock]) -> String {
    content
        .iter()
        .filter_map(|b| match b {
            ContentBlock::Text { text } => Some(text.as_str()),
            _ => None,
        })
        .collect::<Vec<_>>()
        .join("\n")
}

/// Parse a judge reply for a `GOAL_MET:` verdict line. Recognizes
/// `GOAL_MET: YES` (satisfied) and `GOAL_MET: NO[: reason]` (not satisfied).
/// An unrecognized/empty reply is treated as NOT satisfied (the safe direction
/// against over-reporting), with a generic reason.
pub(crate) fn parse_goal_verdict(text: &str) -> GoalVerdict {
    for line in text.lines() {
        let trimmed = line.trim();
        if let Some(rest) = trimmed.strip_prefix("GOAL_MET:") {
            let rest = rest.trim();
            // AC2: accept any reply whose verdict token is `yes`, with optional
            // trailing punctuation/justification (`YES — all criteria met`,
            // `YES (verified)`, `YES, done`). Models routinely append a clause
            // despite the one-line instruction; the NO branch already tolerates
            // trailing prose, so YES must too — the previous exact-match check
            // rejected genuine successes and burned the continuation budget.
            let verdict_token: String = rest
                .chars()
                .take_while(|c| c.is_ascii_alphabetic())
                .collect();
            if verdict_token.eq_ignore_ascii_case("yes") {
                return GoalVerdict {
                    satisfied: true,
                    reason: String::new(),
                };
            }
            if let Some(reason) = rest
                .strip_prefix("NO:")
                .or_else(|| rest.strip_prefix("no:"))
                .or_else(|| rest.strip_prefix("No:"))
            {
                let reason = reason.trim();
                return GoalVerdict {
                    satisfied: false,
                    reason: if reason.is_empty() {
                        "objective not yet demonstrated".to_string()
                    } else {
                        reason.to_string()
                    },
                };
            }
            if rest.eq_ignore_ascii_case("no") || rest.eq_ignore_ascii_case("no.") {
                return GoalVerdict {
                    satisfied: false,
                    reason: "objective not yet demonstrated".to_string(),
                };
            }
            // A GOAL_MET: line that is neither yes nor no — keep scanning.
        }
    }
    GoalVerdict {
        satisfied: false,
        reason: "judge did not return a recognized verdict; continuing".to_string(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::Mutex;

    /// Captures the judge request and returns a canned reply.
    struct StubJudge {
        reply: String,
        captured: Mutex<Vec<CompletionRequest>>,
    }
    impl LlmProvider for StubJudge {
        async fn complete(
            &self,
            request: CompletionRequest,
        ) -> Result<crate::llm::types::CompletionResponse, crate::error::Error> {
            self.captured.lock().expect("lock").push(request);
            Ok(crate::llm::types::CompletionResponse {
                content: vec![ContentBlock::Text {
                    text: self.reply.clone(),
                }],
                stop_reason: crate::llm::types::StopReason::EndTurn,
                reasoning: None,
                usage: TokenUsage::default(),
                model: None,
            })
        }
    }

    #[tokio::test]
    async fn judge_reports_per_criterion_status() {
        // P8 (opt-in): the judge evaluates EACH criterion separately and the
        // continuation guidance names the SPECIFIC unmet criterion.
        let judge = Arc::new(StubJudge {
            reply: "- [met] A: endpoint returns 200\n- [NOT MET] B: no test evidence\n\
                    GOAL_MET: NO: criterion B unmet — tests were never run"
                .into(),
            captured: Mutex::new(Vec::new()),
        });
        let goal = GoalCondition::new(
            "- A: GET /health returns 200\n- B: cargo test green",
            Arc::new(BoxedProvider::from_arc(judge.clone())),
        )
        .with_per_criterion(true);
        let (verdict, _usage) = goal.evaluate("transcript evidence here").await;
        assert!(!verdict.satisfied);
        assert!(
            verdict.reason.contains("criterion B"),
            "reason names the unmet criterion: {}",
            verdict.reason
        );
        let cont = goal.continuation_message(&verdict.reason);
        assert!(
            cont.contains("criterion B"),
            "continuation carries the specific gap: {cont}"
        );
        // The judge prompt carries the per-criterion instruction…
        let reqs = judge.captured.lock().expect("lock");
        assert!(
            reqs[0].system.contains("EACH criterion"),
            "per-criterion instruction missing: {}",
            reqs[0].system
        );
    }

    #[tokio::test]
    async fn default_judge_prompt_has_no_per_criterion_instruction() {
        let judge = Arc::new(StubJudge {
            reply: "GOAL_MET: YES".into(),
            captured: Mutex::new(Vec::new()),
        });
        let goal = GoalCondition::new("ship it", Arc::new(BoxedProvider::from_arc(judge.clone())));
        let _ = goal.evaluate("evidence").await;
        let reqs = judge.captured.lock().expect("lock");
        assert!(
            !reqs[0].system.contains("EACH criterion"),
            "off by default (cost): {}",
            reqs[0].system
        );
    }

    #[test]
    fn parses_yes() {
        let v = parse_goal_verdict("Looks complete.\nGOAL_MET: YES");
        assert!(v.satisfied);
        assert!(v.reason.is_empty());
    }

    #[test]
    fn parses_no_with_reason() {
        let v = parse_goal_verdict("GOAL_MET: NO: tests have not been run yet");
        assert!(!v.satisfied);
        assert_eq!(v.reason, "tests have not been run yet");
    }

    #[test]
    fn parses_bare_no() {
        let v = parse_goal_verdict("GOAL_MET: NO");
        assert!(!v.satisfied);
        assert!(!v.reason.is_empty());
    }

    #[test]
    fn verdict_value_is_case_insensitive() {
        // The `GOAL_MET:` key is matched verbatim (canonical upper form); the
        // YES/NO value is case-insensitive.
        assert!(parse_goal_verdict("GOAL_MET: yes").satisfied);
        assert!(parse_goal_verdict("GOAL_MET: YES").satisfied);
        assert!(!parse_goal_verdict("GOAL_MET: no: x").satisfied);
        assert!(!parse_goal_verdict("GOAL_MET: NO: x").satisfied);
    }

    #[test]
    fn verdict_yes_with_trailing_justification_is_satisfied() {
        // AC2: a YES verdict with an appended clause must still count as met
        // (matches the NO branch's tolerance of trailing prose).
        assert!(parse_goal_verdict("GOAL_MET: YES — all criteria met").satisfied);
        assert!(parse_goal_verdict("GOAL_MET: YES (verified against tests)").satisfied);
        assert!(parse_goal_verdict("GOAL_MET: yes, the objective is demonstrated").satisfied);
        // But a non-yes leading word must NOT be mistaken for yes.
        assert!(!parse_goal_verdict("GOAL_MET: yesterday it was incomplete").satisfied);
    }

    #[test]
    fn unrecognized_reply_is_not_satisfied() {
        // The safe direction: an agent's "I'm done!" with no verdict must NOT
        // count as met (anti over-report).
        let v = parse_goal_verdict("I have completed everything successfully!");
        assert!(!v.satisfied, "no verdict line must not count as met");
        assert!(!v.reason.is_empty());
    }

    #[test]
    fn empty_reply_is_not_satisfied() {
        assert!(!parse_goal_verdict("").satisfied);
    }

    #[test]
    fn builder_defaults_and_overrides() {
        use crate::agent::test_helpers::MockProvider;
        let judge = Arc::new(BoxedProvider::new(MockProvider::new(vec![])));
        let g = GoalCondition::new("ship it", Arc::clone(&judge));
        assert_eq!(g.max_continuations(), DEFAULT_MAX_CONTINUATIONS);
        assert_eq!(g.objective(), "ship it");
        let g2 = GoalCondition::new("ship it", judge).with_max_continuations(2);
        assert_eq!(g2.max_continuations(), 2);
    }

    #[test]
    fn continuation_message_recites_objective_and_reason() {
        use crate::agent::test_helpers::MockProvider;
        let judge = Arc::new(BoxedProvider::new(MockProvider::new(vec![])));
        let g = GoalCondition::new("all tests pass", judge);
        let msg = g.continuation_message("tests are still failing");
        assert!(msg.contains("tests are still failing"));
        assert!(msg.contains("all tests pass"));
    }

    #[tokio::test]
    async fn evaluate_uses_independent_judge_and_parses_yes() {
        use crate::agent::test_helpers::MockProvider;
        let judge = Arc::new(BoxedProvider::new(MockProvider::new(vec![
            MockProvider::text_response("GOAL_MET: YES", 5, 3),
        ])));
        let g = GoalCondition::new("do the thing", judge);
        let (v, usage) = g.evaluate("I did the thing, here is the result X.").await;
        assert!(v.satisfied);
        // The judge's tokens are returned so the caller can account them.
        assert!(usage.input_tokens + usage.output_tokens > 0);
    }

    #[tokio::test]
    async fn evaluate_judge_error_fails_toward_not_met() {
        use crate::agent::test_helpers::MockProvider;
        // Empty mock → the next complete() errors ("no more mock responses").
        let judge = Arc::new(BoxedProvider::new(MockProvider::new(vec![])));
        let g = GoalCondition::new("do the thing", judge);
        let (v, usage) = g.evaluate("anything").await;
        assert!(!v.satisfied, "a judge error must not declare the goal met");
        assert_eq!(
            usage.input_tokens + usage.output_tokens,
            0,
            "a failed judge call contributes no usage"
        );
    }

    #[test]
    fn transcript_tail_keeps_recent_evidence() {
        let long: String = (0..5000).map(|i| format!("line {i}\n")).collect();
        let t = tail_chars(&long, 100);
        assert!(t.starts_with("[... earlier turns omitted ...]"));
        // The TAIL (recent evidence) is kept, not the head.
        assert!(t.contains("line 4999"));
        assert!(!t.contains("line 0\n"));
        // Short input is returned unchanged.
        assert_eq!(tail_chars("short", 100), "short");
    }

    // ===== End-to-end runner integration (mutation-verifiable) =====

    use std::sync::atomic::{AtomicUsize, Ordering};

    use crate::agent::AgentRunner;
    use crate::error::Error;
    use crate::llm::types::{
        CompletionRequest, CompletionResponse, ContentBlock, StopReason, TokenUsage,
    };

    /// A worker provider that natural-completes every turn with a fixed text and
    /// counts how many times it was invoked.
    struct CountingWorker {
        text: String,
        calls: Arc<AtomicUsize>,
    }
    impl LlmProvider for CountingWorker {
        async fn complete(&self, _request: CompletionRequest) -> Result<CompletionResponse, Error> {
            self.calls.fetch_add(1, Ordering::SeqCst);
            Ok(CompletionResponse {
                content: vec![ContentBlock::Text {
                    text: self.text.clone(),
                }],
                stop_reason: StopReason::EndTurn,
                reasoning: None,
                usage: TokenUsage {
                    input_tokens: 1,
                    output_tokens: 1,
                    ..Default::default()
                },
                model: None,
            })
        }
        fn model_name(&self) -> Option<&str> {
            Some("worker-mock")
        }
    }

    /// A judge provider that always returns the same verdict line.
    struct FixedJudge(&'static str);
    impl LlmProvider for FixedJudge {
        async fn complete(&self, _request: CompletionRequest) -> Result<CompletionResponse, Error> {
            Ok(CompletionResponse {
                content: vec![ContentBlock::Text {
                    text: self.0.to_string(),
                }],
                stop_reason: StopReason::EndTurn,
                reasoning: None,
                usage: TokenUsage {
                    input_tokens: 1,
                    output_tokens: 1,
                    ..Default::default()
                },
                model: None,
            })
        }
        fn model_name(&self) -> Option<&str> {
            Some("judge-mock")
        }
    }

    fn worker(text: &str) -> (Arc<CountingWorker>, Arc<AtomicUsize>) {
        let calls = Arc::new(AtomicUsize::new(0));
        let w = Arc::new(CountingWorker {
            text: text.to_string(),
            calls: Arc::clone(&calls),
        });
        (w, calls)
    }

    fn judge(verdict: &'static str) -> Arc<BoxedProvider> {
        Arc::new(BoxedProvider::new(FixedJudge(verdict)))
    }

    /// MUTATION-VERIFIED (always-satisfied): the judge confirming the goal stops
    /// the run after the FIRST natural completion. If the interception ignored
    /// the judge's "yes" and kept looping, worker calls would exceed 1.
    #[tokio::test]
    async fn goal_satisfied_stops_after_one_turn() {
        let (w, calls) = worker("I am done.");
        let runner = AgentRunner::builder(w)
            .name("w")
            .system_prompt("sp")
            .max_turns(10)
            .goal(GoalCondition::new("obj", judge("GOAL_MET: YES")).with_max_continuations(5))
            .build()
            .expect("build");
        let out = runner.execute("task").await.expect("run ok");
        assert_eq!(out.goal_met, Some(true));
        assert_eq!(
            calls.load(Ordering::SeqCst),
            1,
            "satisfied → exactly one turn"
        );
    }

    /// MUTATION-VERIFIED (always-unsatisfied): an always-NO judge loops to the
    /// continuation cap and then returns `goal_met = Some(false)` — never
    /// infinite. With cap=2: initial completion + 2 continuations = 3 worker
    /// turns, then cap-exhausted.
    #[tokio::test]
    async fn goal_unsatisfied_loops_to_cap_then_reports_false() {
        let (w, calls) = worker("I am done, everything works!");
        let runner = AgentRunner::builder(w)
            .name("w")
            .system_prompt("sp")
            .max_turns(50)
            .goal(
                GoalCondition::new("obj", judge("GOAL_MET: NO: not yet")).with_max_continuations(2),
            )
            .build()
            .expect("build");
        let out = runner.execute("task").await.expect("run ok");
        assert_eq!(out.goal_met, Some(false), "cap exhausted → goal unmet");
        assert_eq!(
            calls.load(Ordering::SeqCst),
            3,
            "initial completion + 2 continuations"
        );
    }

    /// BEHAVIORAL INDEPENDENCE: the worker asserts it is done ("everything
    /// works!"), but the independent judge says NO — and the run CONTINUES rather
    /// than trusting the worker's self-assessment (anti over-report). Proven by
    /// the worker being invoked more than once.
    #[tokio::test]
    async fn worker_self_claim_does_not_satisfy_an_independent_no_judge() {
        let (w, calls) = worker("Done! Everything is complete and verified.");
        let runner = AgentRunner::builder(w)
            .name("w")
            .system_prompt("sp")
            .max_turns(50)
            .goal(
                GoalCondition::new("obj", judge("GOAL_MET: NO: show the test output"))
                    .with_max_continuations(1),
            )
            .build()
            .expect("build");
        let out = runner.execute("task").await.expect("run ok");
        assert_eq!(out.goal_met, Some(false));
        assert!(
            calls.load(Ordering::SeqCst) > 1,
            "the worker's 'I am done' claim must NOT stop the run when the judge says no"
        );
    }

    /// The goal continuation cap is LAYERED ON max_turns, never resetting the
    /// turn counter: with a low max_turns and an always-NO judge, the run hits
    /// MaxTurnsExceeded (a reported error exit) rather than looping forever.
    #[tokio::test]
    async fn goal_continuations_respect_max_turns() {
        let (w, _calls) = worker("not done yet");
        let runner = AgentRunner::builder(w)
            .name("w")
            .system_prompt("sp")
            .max_turns(2)
            .goal(
                GoalCondition::new("obj", judge("GOAL_MET: NO: keep going"))
                    .with_max_continuations(1000),
            )
            .build()
            .expect("build");
        let result = runner.execute("task").await;
        // execute() wraps the error in Error::WithPartialUsage; unwrap to the source.
        let err = result.expect_err("should hit max_turns, not loop forever");
        let inner = match err {
            Error::WithPartialUsage { source, .. } => *source,
            other => other,
        };
        assert!(
            matches!(inner, Error::MaxTurnsExceeded(2)),
            "goal continuations must be bounded by max_turns, got {inner:?}"
        );
    }

    /// No goal set → `goal_met` is `None` and the run completes in one turn
    /// (goal gating is inert unless a goal is configured).
    #[tokio::test]
    async fn no_goal_leaves_goal_met_none_and_one_turn() {
        let (w, calls) = worker("done");
        let runner = AgentRunner::builder(w)
            .name("w")
            .system_prompt("sp")
            .max_turns(10)
            .build()
            .expect("build");
        let out = runner.execute("task").await.expect("run ok");
        assert_eq!(out.goal_met, None);
        assert_eq!(calls.load(Ordering::SeqCst), 1);
    }

    /// CONVERGENCE: a judge that says NO once then YES proves a continuation can
    /// actually REACH the goal (not just always-stop / always-loop). Worker
    /// completes twice; the second judge verdict ends the run met.
    #[tokio::test]
    async fn goal_converges_when_judge_flips_to_yes() {
        use crate::agent::test_helpers::MockProvider;
        let (w, calls) = worker("working on it");
        // Judge: NO on the first completion, YES on the second.
        let judge_p = Arc::new(BoxedProvider::new(MockProvider::new(vec![
            MockProvider::text_response("GOAL_MET: NO: not yet", 1, 1),
            MockProvider::text_response("GOAL_MET: YES", 1, 1),
        ])));
        let runner = AgentRunner::builder(w)
            .name("w")
            .system_prompt("sp")
            .max_turns(10)
            .goal(GoalCondition::new("obj", judge_p).with_max_continuations(5))
            .build()
            .expect("build");
        let out = runner.execute("task").await.expect("run ok");
        assert_eq!(
            out.goal_met,
            Some(true),
            "the second verdict reaches the goal"
        );
        assert_eq!(
            calls.load(Ordering::SeqCst),
            2,
            "1 initial completion (judged NO) + 1 continuation (judged YES)"
        );
    }

    /// `max_continuations = 0`: the judge gates the exit ONCE but never
    /// re-prompts. An unmet goal returns immediately with `goal_met = Some(false)`
    /// and exactly one turn.
    #[tokio::test]
    async fn zero_continuations_judges_once_no_reprompt() {
        let (w, calls) = worker("I am done");
        let runner = AgentRunner::builder(w)
            .name("w")
            .system_prompt("sp")
            .max_turns(10)
            .goal(GoalCondition::new("obj", judge("GOAL_MET: NO: nope")).with_max_continuations(0))
            .build()
            .expect("build");
        let out = runner.execute("task").await.expect("run ok");
        assert_eq!(out.goal_met, Some(false));
        assert_eq!(
            calls.load(Ordering::SeqCst),
            1,
            "judged once, no continuation"
        );
    }

    /// A goal and structured output are mutually exclusive at build time (a goal
    /// gates the text completion exit, which structured output bypasses).
    #[test]
    fn goal_with_structured_schema_is_rejected_at_build() {
        let (w, _calls) = worker("x");
        let result = AgentRunner::builder(w)
            .name("w")
            .system_prompt("sp")
            .structured_schema(serde_json::json!({"type": "object"}))
            .goal(GoalCondition::new("obj", judge("GOAL_MET: YES")))
            .build();
        // `AgentRunner` is not `Debug`, so match rather than `unwrap_err`.
        let err = match result {
            Err(e) => e,
            Ok(_) => panic!("goal + structured_schema must be rejected at build"),
        };
        assert!(err.to_string().contains("mutually exclusive"), "got: {err}");
    }
}