Skip to main content

runifold_agent/
terminal_review.rs

1//! Agent terminal-output review contracts and deterministic rule adapters.
2
3use std::{fmt, sync::Arc};
4
5use runifold_core::{CapabilitySet, RunContext};
6use runifold_model::{ContentPart, FinishReason, Message, ModelResponse};
7use serde::{Deserialize, Serialize};
8use serde_json::{Value, json};
9use sha2::{Digest, Sha256};
10use thiserror::Error;
11
12use crate::AgentFuture;
13
14const MAX_REVIEW_FEEDBACK_BYTES: usize = 65_536;
15const MAX_REVIEW_REQUEST_BYTES: usize = 1_048_576;
16const MAX_REJECTION_REASON_BYTES: usize = 4_096;
17const MAX_REVIEWER_NAME_BYTES: usize = 128;
18const MAX_REVIEW_ERROR_BYTES: usize = 4_096;
19const MAX_DESCRIPTOR_CONFIGURATION_BYTES: usize = 65_536;
20
21#[derive(Clone)]
22pub(crate) struct TerminalReviewConfig {
23    pub(crate) reviewer: Arc<dyn TerminalReviewer>,
24    pub(crate) descriptor: TerminalReviewerDescriptor,
25    pub(crate) policy: TerminalReviewPolicy,
26    pub(crate) capabilities: CapabilitySet,
27}
28
29#[derive(Clone)]
30pub(crate) struct TurnReviewConfig {
31    pub(crate) reviewer: Arc<dyn TurnReviewer>,
32    pub(crate) descriptor: TerminalReviewerDescriptor,
33    pub(crate) policy: TurnReviewPolicy,
34    pub(crate) capabilities: CapabilitySet,
35}
36
37impl fmt::Debug for TurnReviewConfig {
38    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
39        formatter
40            .debug_struct("TurnReviewConfig")
41            .field("descriptor", &self.descriptor)
42            .field("policy", &self.policy)
43            .field("capabilities", &self.capabilities)
44            .finish_non_exhaustive()
45    }
46}
47
48impl fmt::Debug for TerminalReviewConfig {
49    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
50        formatter
51            .debug_struct("TerminalReviewConfig")
52            .field("descriptor", &self.descriptor)
53            .field("policy", &self.policy)
54            .field("capabilities", &self.capabilities)
55            .finish_non_exhaustive()
56    }
57}
58
59/// Stable identity bound to terminal-review checkpoints.
60#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
61pub struct TerminalReviewerDescriptor {
62    name: String,
63    version: String,
64    configuration_sha256: String,
65}
66
67impl TerminalReviewerDescriptor {
68    /// Creates a validated descriptor and hashes canonical configuration JSON.
69    ///
70    /// # Errors
71    ///
72    /// Returns [`TerminalReviewError::InvalidConfiguration`] for invalid
73    /// identifiers or configuration larger than 64 KiB.
74    pub fn new(
75        name: impl Into<String>,
76        version: impl Into<String>,
77        configuration: &Value,
78    ) -> Result<Self, TerminalReviewError> {
79        let name = name.into();
80        let version = version.into();
81        validate_identifier("terminal reviewer name", &name)?;
82        validate_identifier("terminal reviewer version", &version)?;
83        let encoded = serde_json::to_vec(configuration)
84            .map_err(|error| TerminalReviewError::InvalidConfiguration(error.to_string()))?;
85        if encoded.len() > MAX_DESCRIPTOR_CONFIGURATION_BYTES {
86            return Err(TerminalReviewError::InvalidConfiguration(format!(
87                "terminal reviewer configuration exceeds {MAX_DESCRIPTOR_CONFIGURATION_BYTES} bytes"
88            )));
89        }
90        let digest = Sha256::digest(encoded);
91        let configuration_sha256 = lowercase_hex(&digest);
92        Ok(Self {
93            name,
94            version,
95            configuration_sha256,
96        })
97    }
98
99    /// Returns the stable reviewer name.
100    pub fn name(&self) -> &str {
101        &self.name
102    }
103
104    /// Returns the caller-managed reviewer version.
105    pub fn version(&self) -> &str {
106        &self.version
107    }
108
109    /// Returns the lowercase SHA-256 configuration fingerprint.
110    pub fn configuration_sha256(&self) -> &str {
111        &self.configuration_sha256
112    }
113}
114
115/// Bounded policy for semantic review of locally valid terminal candidates.
116///
117/// Review repairs consume ordinary model turns and the shared run-tree budget.
118/// A zero repair limit still runs the reviewer, but a repair verdict terminates
119/// immediately as exhausted.
120#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
121pub struct TerminalReviewPolicy {
122    max_repairs: u32,
123}
124
125impl TerminalReviewPolicy {
126    /// Creates a policy with an explicit maximum number of review repairs.
127    pub const fn new(max_repairs: u32) -> Self {
128        Self { max_repairs }
129    }
130
131    /// Returns the maximum number of review-triggered regeneration turns.
132    pub const fn max_repairs(self) -> u32 {
133        self.max_repairs
134    }
135}
136
137/// Model responses selected for internal turn review.
138#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
139#[serde(rename_all = "snake_case")]
140#[non_exhaustive]
141pub enum TurnReviewScope {
142    /// Review tool plans and provider continuation responses, but leave the
143    /// final response to the independently configured terminal reviewer.
144    #[default]
145    IntermediateOnly,
146    /// Review every model response, including the final response.
147    EveryModelResponse,
148}
149
150impl TurnReviewScope {
151    pub(crate) fn includes(self, response: &ModelResponse) -> bool {
152        match self {
153            Self::EveryModelResponse => true,
154            Self::IntermediateOnly => {
155                response
156                    .content
157                    .iter()
158                    .any(|part| matches!(part, ContentPart::ToolCall(_)))
159                    || matches!(
160                        &response.finish_reason,
161                        FinishReason::Other(reason) if reason == "pause_turn"
162                    )
163            }
164        }
165    }
166}
167
168/// Bounded policy for reviewing internal model turns before side effects.
169///
170/// The repair limit is cumulative across one Agent execution. Repairs consume
171/// ordinary model turns and the shared run-tree budget.
172#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
173pub struct TurnReviewPolicy {
174    max_repairs: u32,
175    scope: TurnReviewScope,
176}
177
178impl TurnReviewPolicy {
179    /// Creates an intermediate-only policy with a total repair limit.
180    pub const fn new(max_repairs: u32) -> Self {
181        Self {
182            max_repairs,
183            scope: TurnReviewScope::IntermediateOnly,
184        }
185    }
186
187    /// Selects which model responses enter turn review.
188    #[must_use]
189    pub const fn with_scope(mut self, scope: TurnReviewScope) -> Self {
190        self.scope = scope;
191        self
192    }
193
194    /// Returns the maximum number of review-triggered replacement turns.
195    pub const fn max_repairs(self) -> u32 {
196        self.max_repairs
197    }
198
199    /// Returns the selected internal review scope.
200    pub const fn scope(self) -> TurnReviewScope {
201        self.scope
202    }
203}
204
205/// Canonical input supplied to an Agent terminal reviewer.
206#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
207pub struct TerminalReviewRequest {
208    /// Stable generator Agent name.
209    pub agent: String,
210    /// One-based generator model turn that produced the candidate.
211    pub turn: u32,
212    /// One-based semantic review attempt.
213    pub attempt: u32,
214    /// Stable transcript before the candidate is accepted.
215    pub transcript: Vec<Message>,
216    /// Locally valid terminal candidate awaiting semantic review.
217    pub candidate: ModelResponse,
218}
219
220impl TerminalReviewRequest {
221    /// Validates the complete canonical reviewer request size.
222    ///
223    /// # Errors
224    ///
225    /// Returns [`TerminalReviewError::RequestTooLarge`] when the serialized
226    /// transcript and candidate exceed 1 MiB.
227    pub fn validate(&self) -> Result<(), TerminalReviewError> {
228        let bytes = serde_json::to_vec(self)
229            .map_err(|error| TerminalReviewError::InvalidVerdict(error.to_string()))?
230            .len();
231        if bytes > MAX_REVIEW_REQUEST_BYTES {
232            return Err(TerminalReviewError::RequestTooLarge {
233                bytes,
234                maximum: MAX_REVIEW_REQUEST_BYTES,
235            });
236        }
237        Ok(())
238    }
239}
240
241/// Canonical input supplied before one model response can affect the Agent.
242#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
243pub struct TurnReviewRequest {
244    /// Stable generator Agent name.
245    pub agent: String,
246    /// One-based model turn that produced the response.
247    pub turn: u32,
248    /// Stable transcript before the response is accepted.
249    pub transcript: Vec<Message>,
250    /// Model response awaiting review before tool execution or completion.
251    pub candidate: ModelResponse,
252}
253
254impl TurnReviewRequest {
255    /// Validates the complete canonical reviewer request size.
256    ///
257    /// # Errors
258    ///
259    /// Returns [`TerminalReviewError::RequestTooLarge`] when the serialized
260    /// transcript and candidate exceed 1 MiB.
261    pub fn validate(&self) -> Result<(), TerminalReviewError> {
262        let bytes = serde_json::to_vec(self)
263            .map_err(|error| TerminalReviewError::InvalidVerdict(error.to_string()))?
264            .len();
265        if bytes > MAX_REVIEW_REQUEST_BYTES {
266            return Err(TerminalReviewError::RequestTooLarge {
267                bytes,
268                maximum: MAX_REVIEW_REQUEST_BYTES,
269            });
270        }
271        Ok(())
272    }
273}
274
275/// Stable semantic terminal-review verdict category.
276#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
277#[serde(rename_all = "snake_case")]
278#[non_exhaustive]
279pub enum TerminalReviewVerdictKind {
280    /// Candidate is accepted.
281    Approve,
282    /// Candidate requires bounded regeneration.
283    Repair,
284    /// Candidate is permanently rejected.
285    Reject,
286}
287
288impl TerminalReviewVerdictKind {
289    pub(crate) const fn as_str(self) -> &'static str {
290        match self {
291            Self::Approve => "approve",
292            Self::Repair => "repair",
293            Self::Reject => "reject",
294        }
295    }
296}
297
298/// Semantic decision returned by a terminal reviewer.
299#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
300#[serde(tag = "verdict", rename_all = "snake_case")]
301#[non_exhaustive]
302pub enum TerminalReviewVerdict {
303    /// Commit the candidate as the Agent outcome.
304    Approve,
305    /// Append bounded feedback and ask the original Agent to regenerate.
306    Repair {
307        /// Structured reviewer findings supplied to the generator as data.
308        feedback: Value,
309    },
310    /// Permanently terminate without accepting or regenerating the candidate.
311    Reject {
312        /// Safe, bounded rejection explanation.
313        reason: String,
314    },
315}
316
317impl TerminalReviewVerdict {
318    /// Creates an approval verdict.
319    pub const fn approve() -> Self {
320        Self::Approve
321    }
322
323    /// Creates a validated repair verdict.
324    ///
325    /// # Errors
326    ///
327    /// Returns [`TerminalReviewError::InvalidVerdict`] when feedback is null
328    /// or its canonical JSON representation exceeds 64 KiB.
329    pub fn repair(feedback: Value) -> Result<Self, TerminalReviewError> {
330        let verdict = Self::Repair { feedback };
331        verdict.validate()?;
332        Ok(verdict)
333    }
334
335    /// Creates a validated permanent rejection verdict.
336    ///
337    /// # Errors
338    ///
339    /// Returns [`TerminalReviewError::InvalidVerdict`] when the reason is
340    /// blank or exceeds 4 KiB.
341    pub fn reject(reason: impl Into<String>) -> Result<Self, TerminalReviewError> {
342        let verdict = Self::Reject {
343            reason: reason.into(),
344        };
345        verdict.validate()?;
346        Ok(verdict)
347    }
348
349    /// Validates bounds and required fields before a verdict is acted upon.
350    ///
351    /// # Errors
352    ///
353    /// Returns [`TerminalReviewError::InvalidVerdict`] for invalid feedback or
354    /// rejection text.
355    pub fn validate(&self) -> Result<(), TerminalReviewError> {
356        match self {
357            Self::Approve => Ok(()),
358            Self::Repair { feedback } => {
359                if feedback.is_null() {
360                    return Err(TerminalReviewError::InvalidVerdict(
361                        "repair feedback cannot be null".into(),
362                    ));
363                }
364                let length = serde_json::to_vec(feedback)
365                    .map_err(|error| TerminalReviewError::InvalidVerdict(error.to_string()))?
366                    .len();
367                if length > MAX_REVIEW_FEEDBACK_BYTES {
368                    return Err(TerminalReviewError::InvalidVerdict(format!(
369                        "repair feedback exceeds {MAX_REVIEW_FEEDBACK_BYTES} bytes"
370                    )));
371                }
372                Ok(())
373            }
374            Self::Reject { reason } => validate_bounded_text(
375                "rejection reason",
376                reason,
377                MAX_REJECTION_REASON_BYTES,
378                TerminalReviewError::InvalidVerdict,
379            ),
380        }
381    }
382
383    /// Returns the stable verdict category.
384    pub const fn kind(&self) -> TerminalReviewVerdictKind {
385        match self {
386            Self::Approve => TerminalReviewVerdictKind::Approve,
387            Self::Repair { .. } => TerminalReviewVerdictKind::Repair,
388            Self::Reject { .. } => TerminalReviewVerdictKind::Reject,
389        }
390    }
391}
392
393/// Failure while evaluating a terminal candidate.
394#[derive(Clone, Debug, Error, Eq, PartialEq)]
395#[non_exhaustive]
396pub enum TerminalReviewError {
397    /// Reviewer configuration is invalid.
398    #[error("invalid terminal reviewer configuration: {0}")]
399    InvalidConfiguration(String),
400    /// The transcript and candidate exceed the reviewer request boundary.
401    #[error("terminal review request is {bytes} bytes; maximum is {maximum}")]
402    RequestTooLarge {
403        /// Canonical serialized request size.
404        bytes: usize,
405        /// Maximum accepted request size.
406        maximum: usize,
407    },
408    /// The reviewer could not execute or obtain a decision.
409    #[error("terminal reviewer execution failed: {0}")]
410    Execution(String),
411    /// A reviewer returned an unsafe or malformed decision.
412    #[error("invalid terminal review verdict: {0}")]
413    InvalidVerdict(String),
414}
415
416impl TerminalReviewError {
417    pub(crate) fn bounded(self) -> Self {
418        match self {
419            Self::InvalidConfiguration(message) => {
420                Self::InvalidConfiguration(truncate_utf8(message, MAX_REVIEW_ERROR_BYTES))
421            }
422            Self::Execution(message) => {
423                Self::Execution(truncate_utf8(message, MAX_REVIEW_ERROR_BYTES))
424            }
425            Self::InvalidVerdict(message) => {
426                Self::InvalidVerdict(truncate_utf8(message, MAX_REVIEW_ERROR_BYTES))
427            }
428            Self::RequestTooLarge { bytes, maximum } => Self::RequestTooLarge { bytes, maximum },
429        }
430    }
431}
432
433/// Future returned by a terminal reviewer.
434pub type TerminalReviewFuture<'a> =
435    AgentFuture<'a, Result<TerminalReviewVerdict, TerminalReviewError>>;
436
437/// Pluggable semantic reviewer for locally valid Agent terminal candidates.
438pub trait TerminalReviewer: Send + Sync {
439    /// Returns the stable identity persisted into Agent checkpoints.
440    fn descriptor(&self) -> &TerminalReviewerDescriptor;
441
442    /// Reviews a candidate inside an explicitly attenuated child run.
443    fn review_terminal<'a>(
444        &'a self,
445        request: TerminalReviewRequest,
446        run: &'a RunContext,
447    ) -> TerminalReviewFuture<'a>;
448}
449
450/// Turn-review verdicts use the same bounded approve/repair/reject contract as
451/// terminal review.
452pub type TurnReviewVerdict = TerminalReviewVerdict;
453
454/// Turn-review failures use the same validated and bounded error contract as
455/// terminal review.
456pub type TurnReviewError = TerminalReviewError;
457
458/// Stable descriptor persisted for an internal turn reviewer.
459pub type TurnReviewerDescriptor = TerminalReviewerDescriptor;
460
461/// Future returned by an internal turn reviewer.
462pub type TurnReviewFuture<'a> = AgentFuture<'a, Result<TurnReviewVerdict, TurnReviewError>>;
463
464/// Pluggable reviewer invoked after a model response and before that response
465/// can execute tools or enter terminal completion.
466pub trait TurnReviewer: Send + Sync {
467    /// Returns the stable identity persisted into Agent checkpoints.
468    fn turn_descriptor(&self) -> &TurnReviewerDescriptor;
469
470    /// Reviews one model response inside an explicitly attenuated child run.
471    fn review_turn<'a>(
472        &'a self,
473        request: TurnReviewRequest,
474        run: &'a RunContext,
475    ) -> TurnReviewFuture<'a>;
476}
477
478/// Every terminal reviewer can also review internal turns. The compatibility
479/// mapping preserves the candidate and transcript and uses one attempt because
480/// each generated response enters turn review at most once.
481impl<T> TurnReviewer for T
482where
483    T: TerminalReviewer + ?Sized,
484{
485    fn turn_descriptor(&self) -> &TurnReviewerDescriptor {
486        TerminalReviewer::descriptor(self)
487    }
488
489    fn review_turn<'a>(
490        &'a self,
491        request: TurnReviewRequest,
492        run: &'a RunContext,
493    ) -> TurnReviewFuture<'a> {
494        let terminal = TerminalReviewRequest {
495            agent: request.agent,
496            turn: request.turn,
497            attempt: 1,
498            transcript: request.transcript,
499            candidate: request.candidate,
500        };
501        self.review_terminal(terminal, run)
502    }
503}
504
505type TurnRuleFunction =
506    dyn Fn(&TurnReviewRequest) -> Result<TurnReviewVerdict, TurnReviewError> + Send + Sync;
507
508/// Named deterministic rule adapter for [`TurnReviewer`].
509#[derive(Clone)]
510pub struct TurnRuleReviewer {
511    descriptor: TurnReviewerDescriptor,
512    rule: Arc<TurnRuleFunction>,
513}
514
515impl TurnRuleReviewer {
516    /// Creates a validated deterministic internal-turn reviewer.
517    ///
518    /// # Errors
519    ///
520    /// Returns [`TurnReviewError::InvalidConfiguration`] for invalid stable
521    /// identity fields.
522    pub fn new<F>(
523        name: impl Into<String>,
524        version: impl Into<String>,
525        rule: F,
526    ) -> Result<Self, TurnReviewError>
527    where
528        F: Fn(&TurnReviewRequest) -> Result<TurnReviewVerdict, TurnReviewError>
529            + Send
530            + Sync
531            + 'static,
532    {
533        let name = name.into();
534        let version = version.into();
535        let descriptor = TurnReviewerDescriptor::new(name, version, &json!({"kind": "turn_rule"}))?;
536        Ok(Self {
537            descriptor,
538            rule: Arc::new(rule),
539        })
540    }
541
542    /// Returns the stable rule reviewer name.
543    pub fn name(&self) -> &str {
544        self.descriptor.name()
545    }
546}
547
548impl TurnReviewer for TurnRuleReviewer {
549    fn turn_descriptor(&self) -> &TurnReviewerDescriptor {
550        &self.descriptor
551    }
552
553    fn review_turn<'a>(
554        &'a self,
555        request: TurnReviewRequest,
556        _run: &'a RunContext,
557    ) -> TurnReviewFuture<'a> {
558        let verdict = request
559            .validate()
560            .and_then(|()| (self.rule)(&request))
561            .and_then(|verdict| {
562                verdict.validate()?;
563                Ok(verdict)
564            });
565        Box::pin(async move { verdict })
566    }
567}
568
569impl fmt::Debug for TurnRuleReviewer {
570    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
571        formatter
572            .debug_struct("TurnRuleReviewer")
573            .field("descriptor", &self.descriptor)
574            .finish_non_exhaustive()
575    }
576}
577
578type RuleFunction = dyn Fn(&TerminalReviewRequest) -> Result<TerminalReviewVerdict, TerminalReviewError>
579    + Send
580    + Sync;
581
582/// Named deterministic rule adapter for [`TerminalReviewer`].
583#[derive(Clone)]
584pub struct TerminalRuleReviewer {
585    descriptor: TerminalReviewerDescriptor,
586    rule: Arc<RuleFunction>,
587}
588
589impl TerminalRuleReviewer {
590    /// Creates a validated deterministic terminal reviewer.
591    ///
592    /// # Errors
593    ///
594    /// Returns [`TerminalReviewError::InvalidConfiguration`] when the reviewer name
595    /// is blank, oversized, or is not a stable identifier.
596    pub fn new<F>(
597        name: impl Into<String>,
598        version: impl Into<String>,
599        rule: F,
600    ) -> Result<Self, TerminalReviewError>
601    where
602        F: Fn(&TerminalReviewRequest) -> Result<TerminalReviewVerdict, TerminalReviewError>
603            + Send
604            + Sync
605            + 'static,
606    {
607        let name = name.into();
608        let version = version.into();
609        let descriptor =
610            TerminalReviewerDescriptor::new(name, version, &json!({"kind": "terminal_rule"}))?;
611        Ok(Self {
612            descriptor,
613            rule: Arc::new(rule),
614        })
615    }
616
617    /// Returns the stable rule reviewer name.
618    pub fn name(&self) -> &str {
619        self.descriptor.name()
620    }
621}
622
623impl TerminalReviewer for TerminalRuleReviewer {
624    fn descriptor(&self) -> &TerminalReviewerDescriptor {
625        &self.descriptor
626    }
627
628    fn review_terminal<'a>(
629        &'a self,
630        request: TerminalReviewRequest,
631        _run: &'a RunContext,
632    ) -> TerminalReviewFuture<'a> {
633        let verdict = request
634            .validate()
635            .and_then(|()| (self.rule)(&request))
636            .and_then(|verdict| {
637                verdict.validate()?;
638                Ok(verdict)
639            });
640        Box::pin(async move { verdict })
641    }
642}
643
644impl fmt::Debug for TerminalRuleReviewer {
645    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
646        formatter
647            .debug_struct("TerminalRuleReviewer")
648            .field("descriptor", &self.descriptor)
649            .finish_non_exhaustive()
650    }
651}
652
653/// How a [`CompositeTerminalReviewer`] processes repair verdicts.
654#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
655#[serde(rename_all = "snake_case")]
656#[non_exhaustive]
657pub enum CompositeTerminalReviewMode {
658    /// Run every reviewer, reject immediately, and merge repair feedback.
659    #[default]
660    AllMustApprove,
661    /// Return on the first repair or rejection.
662    FirstFailure,
663}
664
665#[derive(Clone)]
666struct TerminalReviewerEntry {
667    name: String,
668    reviewer: Arc<dyn TerminalReviewer>,
669}
670
671/// Deterministic sequential composition of terminal reviewers.
672#[derive(Clone)]
673pub struct CompositeTerminalReviewer {
674    name: String,
675    version: String,
676    mode: CompositeTerminalReviewMode,
677    descriptor: TerminalReviewerDescriptor,
678    reviewers: Vec<TerminalReviewerEntry>,
679}
680
681impl CompositeTerminalReviewer {
682    /// Creates an empty composition with stable identity.
683    ///
684    /// Add at least one reviewer before execution.
685    ///
686    /// # Errors
687    ///
688    /// Returns [`TerminalReviewError::InvalidConfiguration`] for invalid
689    /// identity fields.
690    pub fn new(
691        name: impl Into<String>,
692        version: impl Into<String>,
693        mode: CompositeTerminalReviewMode,
694    ) -> Result<Self, TerminalReviewError> {
695        let name = name.into();
696        let version = version.into();
697        let descriptor = composite_descriptor(&name, &version, mode, &[])?;
698        Ok(Self {
699            name,
700            version,
701            mode,
702            descriptor,
703            reviewers: Vec::new(),
704        })
705    }
706
707    /// Appends a uniquely named owned reviewer.
708    ///
709    /// # Errors
710    ///
711    /// Returns [`TerminalReviewError::InvalidConfiguration`] for an invalid or
712    /// duplicate entry name, or an oversized composite descriptor.
713    pub fn push<R>(
714        &mut self,
715        name: impl Into<String>,
716        reviewer: R,
717    ) -> Result<(), TerminalReviewError>
718    where
719        R: TerminalReviewer + 'static,
720    {
721        self.push_shared(name, Arc::new(reviewer))
722    }
723
724    /// Appends a uniquely named shared reviewer.
725    ///
726    /// # Errors
727    ///
728    /// Returns [`TerminalReviewError::InvalidConfiguration`] for an invalid or
729    /// duplicate entry name, or an oversized composite descriptor.
730    pub fn push_shared(
731        &mut self,
732        name: impl Into<String>,
733        reviewer: Arc<dyn TerminalReviewer>,
734    ) -> Result<(), TerminalReviewError> {
735        let name = name.into();
736        validate_identifier("composite terminal reviewer entry", &name)?;
737        if self.reviewers.iter().any(|entry| entry.name == name) {
738            return Err(TerminalReviewError::InvalidConfiguration(format!(
739                "duplicate composite terminal reviewer entry `{name}`"
740            )));
741        }
742        let mut reviewers = self.reviewers.clone();
743        reviewers.push(TerminalReviewerEntry { name, reviewer });
744        let descriptor = composite_descriptor(&self.name, &self.version, self.mode, &reviewers)?;
745        self.reviewers = reviewers;
746        self.descriptor = descriptor;
747        Ok(())
748    }
749
750    /// Returns the composition strategy.
751    pub const fn mode(&self) -> CompositeTerminalReviewMode {
752        self.mode
753    }
754
755    /// Returns the number of configured reviewers.
756    pub fn len(&self) -> usize {
757        self.reviewers.len()
758    }
759
760    /// Returns whether no reviewers are configured.
761    pub fn is_empty(&self) -> bool {
762        self.reviewers.is_empty()
763    }
764}
765
766impl TerminalReviewer for CompositeTerminalReviewer {
767    fn descriptor(&self) -> &TerminalReviewerDescriptor {
768        &self.descriptor
769    }
770
771    fn review_terminal<'a>(
772        &'a self,
773        request: TerminalReviewRequest,
774        run: &'a RunContext,
775    ) -> TerminalReviewFuture<'a> {
776        Box::pin(async move {
777            request.validate()?;
778            if self.reviewers.is_empty() {
779                return Err(TerminalReviewError::InvalidConfiguration(
780                    "composite terminal reviewer requires at least one reviewer".into(),
781                ));
782            }
783            let mut repairs = Vec::new();
784            for entry in &self.reviewers {
785                let verdict = entry
786                    .reviewer
787                    .review_terminal(request.clone(), run)
788                    .await
789                    .map_err(TerminalReviewError::bounded)?;
790                verdict.validate().map_err(TerminalReviewError::bounded)?;
791                match verdict {
792                    TerminalReviewVerdict::Approve => {}
793                    TerminalReviewVerdict::Reject { reason } => {
794                        return TerminalReviewVerdict::reject(reason);
795                    }
796                    TerminalReviewVerdict::Repair { feedback } => {
797                        if self.mode == CompositeTerminalReviewMode::FirstFailure {
798                            return TerminalReviewVerdict::repair(feedback);
799                        }
800                        repairs.push(json!({
801                            "reviewer": entry.name,
802                            "feedback": feedback,
803                        }));
804                    }
805                }
806            }
807            if repairs.is_empty() {
808                Ok(TerminalReviewVerdict::approve())
809            } else {
810                TerminalReviewVerdict::repair(json!({
811                    "kind": "composite",
812                    "reviews": repairs,
813                }))
814            }
815        })
816    }
817}
818
819impl fmt::Debug for CompositeTerminalReviewer {
820    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
821        formatter
822            .debug_struct("CompositeTerminalReviewer")
823            .field("name", &self.name)
824            .field("version", &self.version)
825            .field("descriptor", &self.descriptor)
826            .field("mode", &self.mode)
827            .field(
828                "reviewers",
829                &self
830                    .reviewers
831                    .iter()
832                    .map(|entry| entry.name.as_str())
833                    .collect::<Vec<_>>(),
834            )
835            .finish()
836    }
837}
838
839fn composite_descriptor(
840    name: &str,
841    version: &str,
842    mode: CompositeTerminalReviewMode,
843    reviewers: &[TerminalReviewerEntry],
844) -> Result<TerminalReviewerDescriptor, TerminalReviewError> {
845    TerminalReviewerDescriptor::new(
846        name,
847        version,
848        &json!({
849            "kind": "composite_terminal",
850            "mode": mode,
851            "reviewers": reviewers.iter().map(|entry| json!({
852                "entry": entry.name,
853                "descriptor": entry.reviewer.descriptor(),
854            })).collect::<Vec<_>>(),
855        }),
856    )
857}
858
859fn validate_identifier(field: &str, value: &str) -> Result<(), TerminalReviewError> {
860    if value.is_empty()
861        || value.len() > MAX_REVIEWER_NAME_BYTES
862        || !value
863            .bytes()
864            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
865    {
866        return Err(TerminalReviewError::InvalidConfiguration(format!(
867            "{field} must contain 1..={MAX_REVIEWER_NAME_BYTES} ASCII letters, digits, `_`, `-`, or `.`"
868        )));
869    }
870    Ok(())
871}
872
873fn truncate_utf8(mut value: String, maximum: usize) -> String {
874    if value.len() <= maximum {
875        return value;
876    }
877    let mut end = maximum;
878    while !value.is_char_boundary(end) {
879        end = end.saturating_sub(1);
880    }
881    value.truncate(end);
882    value
883}
884
885fn lowercase_hex(bytes: &[u8]) -> String {
886    const DIGITS: &[u8; 16] = b"0123456789abcdef";
887    bytes.iter().fold(
888        String::with_capacity(bytes.len().saturating_mul(2)),
889        |mut encoded, byte| {
890            encoded.push(char::from(DIGITS[usize::from(byte >> 4)]));
891            encoded.push(char::from(DIGITS[usize::from(byte & 0x0f)]));
892            encoded
893        },
894    )
895}
896
897pub(crate) fn validate_bounded_text(
898    field: &str,
899    value: &str,
900    maximum: usize,
901    error: fn(String) -> TerminalReviewError,
902) -> Result<(), TerminalReviewError> {
903    if value.trim().is_empty() {
904        return Err(error(format!("{field} cannot be blank")));
905    }
906    if value.len() > maximum {
907        return Err(error(format!("{field} exceeds {maximum} bytes")));
908    }
909    Ok(())
910}
911
912#[cfg(test)]
913mod tests {
914    use serde_json::{Value, json};
915
916    use super::{
917        TerminalReviewError, TerminalReviewVerdict, TerminalReviewerDescriptor,
918        TerminalRuleReviewer,
919    };
920
921    #[test]
922    fn repair_feedback_and_rejection_reasons_are_bounded() {
923        assert!(matches!(
924            TerminalReviewVerdict::repair(Value::Null),
925            Err(TerminalReviewError::InvalidVerdict(_))
926        ));
927        assert!(matches!(
928            TerminalReviewVerdict::repair(json!({"body": "x".repeat(65_537)})),
929            Err(TerminalReviewError::InvalidVerdict(_))
930        ));
931        assert!(matches!(
932            TerminalReviewVerdict::reject("   "),
933            Err(TerminalReviewError::InvalidVerdict(_))
934        ));
935    }
936
937    #[test]
938    fn rule_reviewer_requires_a_stable_name() {
939        let result = TerminalRuleReviewer::new("not a stable name", "v1", |_| {
940            Ok(TerminalReviewVerdict::approve())
941        });
942
943        assert!(matches!(
944            result,
945            Err(TerminalReviewError::InvalidConfiguration(_))
946        ));
947    }
948
949    #[test]
950    fn descriptor_fingerprint_changes_with_configuration() {
951        let first =
952            TerminalReviewerDescriptor::new("review", "v1", &json!({"threshold": 1})).unwrap();
953        let same =
954            TerminalReviewerDescriptor::new("review", "v1", &json!({"threshold": 1})).unwrap();
955        let changed =
956            TerminalReviewerDescriptor::new("review", "v1", &json!({"threshold": 2})).unwrap();
957
958        assert_eq!(first, same);
959        assert_ne!(first, changed);
960        assert_eq!(first.configuration_sha256().len(), 64);
961    }
962
963    #[test]
964    fn reviewer_execution_errors_are_unicode_safely_bounded() {
965        let bounded = TerminalReviewError::Execution("界".repeat(2_000)).bounded();
966
967        let TerminalReviewError::Execution(message) = bounded else {
968            panic!("execution error kind must be retained");
969        };
970        assert!(message.len() <= 4_096);
971        assert!(message.is_char_boundary(message.len()));
972    }
973}