Skip to main content

heartbit_core/agent/
goal.rs

1//! [`GoalCondition`] — a persistent objective that keeps an agent working across
2//! turns until an **independent** judge confirms the objective is met.
3//!
4//! This is heartbit's equivalent of Claude Code's `/goal`: a completion
5//! condition evaluated after the agent would naturally finish a turn. A small,
6//! impartial judge (a *separate* provider call — never the working agent grading
7//! itself) reads the objective plus what the agent has surfaced and returns
8//! met / not-met + a short reason. Not-met re-injects the reason as guidance and
9//! the agent continues; met (or the continuation cap is reached) ends the run.
10//!
11//! ## Why an independent judge (not self-assessment)
12//!
13//! Agents systematically OVER-REPORT success — an agent asked "are you done?"
14//! tends to say yes ("An Illusion of Progress?", arXiv:2504.01382; their WebJudge
15//! shows an *independent* judge over the final state agrees with humans ~85% vs.
16//! inflated self-reports). And an LLM grading its own output is biased toward it
17//! ("Self-Preference Bias in LLM-as-a-Judge", arXiv:2410.21819 — persists even
18//! when authorship is hidden). So the judge is a distinct provider call with an
19//! impartial-evaluator prompt and no tools, mirroring `/goal`'s separate fast
20//! model that "judges only what the agent has surfaced".
21//!
22//! ## Termination
23//!
24//! Bounded on both sides: the judge gates the natural-completion exit (no
25//! premature stop) while `max_continuations` + the agent's own `max_turns` bound
26//! the loop (no infinite loop). On an unparseable/empty judge reply the verdict
27//! is treated as NOT met (keep working) — the safe direction against
28//! over-reporting — still bounded by the cap.
29
30use std::sync::Arc;
31
32use crate::llm::types::{CompletionRequest, ContentBlock, Message, TokenUsage};
33use crate::llm::{BoxedProvider, LlmProvider};
34
35/// Default number of extra continuations granted to reach the goal after the
36/// agent first naturally completes.
37pub const DEFAULT_MAX_CONTINUATIONS: u32 = 8;
38
39/// Max tokens for a judge reply — the verdict is one short line plus a reason.
40const JUDGE_MAX_TOKENS: u32 = 256;
41
42/// Cap on the transcript (tail) shown to the judge, in characters. The judge
43/// must see the EVIDENCE (recent tool results), not just the agent's claim, but
44/// an unbounded transcript would blow its context — so we keep the most recent
45/// tail, where the demonstrating tool output and final answer live.
46const MAX_TRANSCRIPT_CHARS: usize = 12_000;
47
48/// Impartial-evaluator system prompt for the goal judge. It deliberately frames
49/// the judge as a skeptical external verifier (anti over-report) that decides
50/// only from the evidence the agent surfaced.
51const 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
64/// Shared, runtime-mutable goal slot: the runner reads it at every natural
65/// stop; the `set_goal` tool (entry agent) installs/replaces the goal
66/// mid-run — e.g. with the acceptance criteria the `intake` recipe produced.
67/// `std::sync::RwLock` per repo convention (never held across an await; the
68/// goal is CLONED out before the async judge call).
69pub type GoalSlot = Arc<std::sync::RwLock<Option<GoalCondition>>>;
70
71/// A persistent objective evaluated by an independent judge after each
72/// natural completion.
73#[derive(Clone)]
74pub struct GoalCondition {
75    objective: String,
76    judge: Arc<BoxedProvider>,
77    max_continuations: u32,
78    /// When true, the judge grades EACH criterion line of the objective
79    /// independently (checklist verdict). Off by default — it lengthens the
80    /// judge reply and is only worth the cost for multi-criterion objectives.
81    per_criterion: bool,
82}
83
84/// The judge's decision plus its reason (the reason becomes the agent's
85/// next-turn guidance when the goal is not yet met).
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct GoalVerdict {
88    /// Whether the objective is satisfied by the surfaced evidence.
89    pub satisfied: bool,
90    /// Short reason — what remains, when not satisfied.
91    pub reason: String,
92}
93
94impl GoalCondition {
95    /// Create a goal from an `objective` string and an INDEPENDENT judge
96    /// provider (a separate provider/model from the working agent). Uses
97    /// [`DEFAULT_MAX_CONTINUATIONS`].
98    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    /// Opt into per-criterion judging: the judge grades each criterion line of
108    /// the objective separately and names the specific unmet ones — sharper
109    /// continuation guidance for multi-criterion objectives, at the cost of a
110    /// longer judge reply.
111    pub fn with_per_criterion(mut self, on: bool) -> Self {
112        self.per_criterion = on;
113        self
114    }
115
116    /// Override how many extra continuations are granted to reach the goal after
117    /// the agent first naturally completes. `0` means "judge once, never
118    /// continue" (the judge still gates the exit, but no re-prompt is issued).
119    pub fn with_max_continuations(mut self, n: u32) -> Self {
120        self.max_continuations = n;
121        self
122    }
123
124    /// The configured continuation cap.
125    pub fn max_continuations(&self) -> u32 {
126        self.max_continuations
127    }
128
129    /// The objective text (used to recite the goal in continuation guidance).
130    pub fn objective(&self) -> &str {
131        &self.objective
132    }
133
134    /// Whether per-criterion judging is enabled (see [`Self::with_per_criterion`]).
135    pub fn per_criterion(&self) -> bool {
136        self.per_criterion
137    }
138
139    /// Build the guidance message injected when the goal is not yet met: the
140    /// judge's reason plus a recitation of the objective (goal-recitation keeps
141    /// the long-horizon objective in context).
142    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    /// Ask the independent judge whether the objective is met. `transcript` is
151    /// the working conversation rendered to text (including tool results — the
152    /// EVIDENCE), of which the most recent [`MAX_TRANSCRIPT_CHARS`] are shown so
153    /// the judge grades the demonstrated result, not merely the agent's claim.
154    ///
155    /// Returns the verdict plus the judge call's token usage (so the caller can
156    /// account it against the run's budget). A judge or network error fails
157    /// toward NOT-met (keep working) so a flaky judge never prematurely declares
158    /// done — bounded by the continuation cap.
159    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            // One checklist line per criterion needs more room than a bare verdict.
180            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
211/// Keep the last `max` characters of `s` (on a char boundary), prefixed with an
212/// elision marker when truncated. The tail holds the most recent tool results
213/// and the final answer — the judge's evidence.
214fn 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
232/// Concatenate the text content blocks of a completion response.
233fn 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
244/// Parse a judge reply for a `GOAL_MET:` verdict line. Recognizes
245/// `GOAL_MET: YES` (satisfied) and `GOAL_MET: NO[: reason]` (not satisfied).
246/// An unrecognized/empty reply is treated as NOT satisfied (the safe direction
247/// against over-reporting), with a generic reason.
248pub(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            // AC2: accept any reply whose verdict token is `yes`, with optional
254            // trailing punctuation/justification (`YES — all criteria met`,
255            // `YES (verified)`, `YES, done`). Models routinely append a clause
256            // despite the one-line instruction; the NO branch already tolerates
257            // trailing prose, so YES must too — the previous exact-match check
258            // rejected genuine successes and burned the continuation budget.
259            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            // A GOAL_MET: line that is neither yes nor no — keep scanning.
291        }
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    /// Captures the judge request and returns a canned reply.
305    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        // P8 (opt-in): the judge evaluates EACH criterion separately and the
330        // continuation guidance names the SPECIFIC unmet criterion.
331        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        // The judge prompt carries the per-criterion instruction…
355        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        // The `GOAL_MET:` key is matched verbatim (canonical upper form); the
403        // YES/NO value is case-insensitive.
404        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        // AC2: a YES verdict with an appended clause must still count as met
413        // (matches the NO branch's tolerance of trailing prose).
414        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        // But a non-yes leading word must NOT be mistaken for yes.
418        assert!(!parse_goal_verdict("GOAL_MET: yesterday it was incomplete").satisfied);
419    }
420
421    #[test]
422    fn unrecognized_reply_is_not_satisfied() {
423        // The safe direction: an agent's "I'm done!" with no verdict must NOT
424        // count as met (anti over-report).
425        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        // The judge's tokens are returned so the caller can account them.
466        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        // Empty mock → the next complete() errors ("no more mock responses").
473        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        // The TAIL (recent evidence) is kept, not the head.
490        assert!(t.contains("line 4999"));
491        assert!(!t.contains("line 0\n"));
492        // Short input is returned unchanged.
493        assert_eq!(tail_chars("short", 100), "short");
494    }
495
496    // ===== End-to-end runner integration (mutation-verifiable) =====
497
498    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    /// A worker provider that natural-completes every turn with a fixed text and
507    /// counts how many times it was invoked.
508    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    /// A judge provider that always returns the same verdict line.
535    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    /// MUTATION-VERIFIED (always-satisfied): the judge confirming the goal stops
571    /// the run after the FIRST natural completion. If the interception ignored
572    /// the judge's "yes" and kept looping, worker calls would exceed 1.
573    #[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    /// MUTATION-VERIFIED (always-unsatisfied): an always-NO judge loops to the
593    /// continuation cap and then returns `goal_met = Some(false)` — never
594    /// infinite. With cap=2: initial completion + 2 continuations = 3 worker
595    /// turns, then cap-exhausted.
596    #[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    /// BEHAVIORAL INDEPENDENCE: the worker asserts it is done ("everything
618    /// works!"), but the independent judge says NO — and the run CONTINUES rather
619    /// than trusting the worker's self-assessment (anti over-report). Proven by
620    /// the worker being invoked more than once.
621    #[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    /// The goal continuation cap is LAYERED ON max_turns, never resetting the
643    /// turn counter: with a low max_turns and an always-NO judge, the run hits
644    /// MaxTurnsExceeded (a reported error exit) rather than looping forever.
645    #[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        // execute() wraps the error in Error::WithPartialUsage; unwrap to the source.
660        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    /// No goal set → `goal_met` is `None` and the run completes in one turn
672    /// (goal gating is inert unless a goal is configured).
673    #[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    /// CONVERGENCE: a judge that says NO once then YES proves a continuation can
688    /// actually REACH the goal (not just always-stop / always-loop). Worker
689    /// completes twice; the second judge verdict ends the run met.
690    #[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        // Judge: NO on the first completion, YES on the second.
695        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    /// `max_continuations = 0`: the judge gates the exit ONCE but never
720    /// re-prompts. An unmet goal returns immediately with `goal_met = Some(false)`
721    /// and exactly one turn.
722    #[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    /// A goal and structured output are mutually exclusive at build time (a goal
742    /// gates the text completion exit, which structured output bypasses).
743    #[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        // `AgentRunner` is not `Debug`, so match rather than `unwrap_err`.
753        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}