use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::BTreeMap;
use std::collections::BTreeSet;
pub(crate) const PROMPT_COMPREHENSION_MODEL_CARD_PREFIX: &str = "[NEWT PROMPT COMPREHENSION v1]";
const MAX_ATOMIC_ASKS: usize = 64;
const MAX_DECISIONS: usize = 16;
const MAX_CONCRETE_DECISIONS: usize = MAX_DECISIONS - 1;
const MAX_ASK_BYTES: usize = 4_096;
const MAX_CLARIFICATION_BYTES: usize = 384;
pub const MAX_ADJUDICATION_BATCH: usize = 15;
pub(super) const PROMPT_COMPREHENSION_SCHEMA_V1: &str = "prompt_comprehension_manifest_v1";
pub(super) const PROMPT_COMPREHENSION_SCHEMA_V2: &str = "prompt_comprehension_manifest_v2";
pub(super) const PROMPT_COMPREHENSION_SCHEMA_V3: &str = "prompt_comprehension_manifest_v3";
pub(super) const PROMPT_COMPREHENSION_SCHEMA_CURRENT: &str = PROMPT_COMPREHENSION_SCHEMA_V3;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DispositionSource {
Inferred,
SessionPolicy,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PromptDisposition {
Ask,
Act,
Explain,
Research,
Plan,
}
impl PromptDisposition {
pub fn as_str(self) -> &'static str {
match self {
Self::Ask => "ask",
Self::Act => "act",
Self::Explain => "explain",
Self::Research => "research",
Self::Plan => "plan",
}
}
pub fn tool_round_limit(self, max: usize) -> usize {
match self {
Self::Ask => 0,
Self::Act | Self::Explain | Self::Research | Self::Plan => max,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AskKind {
Instruction,
Informational,
}
impl AskKind {
pub fn as_str(self) -> &'static str {
match self {
Self::Instruction => "instruction",
Self::Informational => "informational",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AtomicAsk {
text: String,
kind: AskKind,
}
impl AtomicAsk {
pub fn text(&self) -> &str {
&self.text
}
pub fn kind(&self) -> AskKind {
self.kind
}
pub fn is_informational(&self) -> bool {
self.kind == AskKind::Informational
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DecisionStatus {
Pending,
Locked,
}
impl DecisionStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Pending => "pending",
Self::Locked => "locked",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DecisionSource {
Operator,
Policy,
AuthorizedAssumption,
}
impl DecisionSource {
pub fn as_str(self) -> &'static str {
match self {
Self::Operator => "operator",
Self::Policy => "policy",
Self::AuthorizedAssumption => "authorized_assumption",
}
}
}
#[derive(Debug, Clone)]
pub struct DecisionLock {
question: String,
status: DecisionStatus,
source: Option<DecisionSource>,
assumption: Option<String>,
overflow: bool,
answer: Option<String>,
}
impl DecisionLock {
pub fn question(&self) -> &str {
&self.question
}
pub fn status(&self) -> DecisionStatus {
self.status
}
pub fn source(&self) -> Option<DecisionSource> {
self.source
}
pub fn is_overflow(&self) -> bool {
self.overflow
}
pub fn assumption(&self) -> Option<&str> {
self.assumption.as_deref()
}
pub fn answer_or_question(&self) -> &str {
self.answer.as_deref().unwrap_or(&self.question)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct AdjudicationCandidate {
pub id: usize,
pub question: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct AdjudicationVerdict {
pub decision_id: usize,
#[serde(default)]
pub delegated_to_agent: bool,
#[serde(default)]
pub assumption: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AdjudicationRefusal {
BatchTooLarge { candidates: usize, bound: usize },
}
#[derive(Debug, Clone)]
pub struct PromptComprehensionManifest {
atomic_asks: Vec<AtomicAsk>,
decisions: Vec<DecisionLock>,
}
impl PromptComprehensionManifest {
pub fn atomic_asks(&self) -> &[AtomicAsk] {
&self.atomic_asks
}
pub fn informational_asks(&self) -> impl Iterator<Item = &AtomicAsk> {
self.atomic_asks.iter().filter(|a| a.is_informational())
}
pub fn decision_count(&self) -> usize {
self.decisions.len()
}
pub fn decisions(&self) -> &[DecisionLock] {
&self.decisions
}
pub fn pending_decision_count(&self) -> usize {
self.decisions
.iter()
.filter(|decision| decision.status == DecisionStatus::Pending)
.count()
}
pub fn locked_decision_count(&self) -> usize {
self.decisions
.iter()
.filter(|decision| decision.status == DecisionStatus::Locked)
.count()
}
}
#[derive(Debug, Clone)]
pub struct PromptIntake {
manifest: PromptComprehensionManifest,
disposition: PromptDisposition,
post_lock_disposition: PromptDisposition,
source: DispositionSource,
last_rejection: Option<ClarificationRejection>,
pending_discussion: Option<String>,
proposed_answer: Option<ProposedAnswer>,
last_rejection_proposal: Option<ProposedAnswer>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ProposedAnswer {
index: usize,
summary: String,
}
const CONFIRMATION_WORDS: &[&str] = &[
"y",
"yes",
"yes.",
"yep",
"yeah",
"yup",
"correct",
"right",
"confirm",
"confirmed",
"ok",
"okay",
"sure",
"do it",
"go ahead",
"proceed",
"that one",
"that's right",
"sounds right",
];
impl PromptIntake {
pub fn analyze_with(prompt: &str, lexicon: &DispositionLexicon) -> Self {
let mut intake = Self::analyze(prompt);
if prompt.trim().is_empty() {
return intake; }
let (asks, _) = extract_atomic_asks_with(prompt, lexicon);
intake.post_lock_disposition = infer_disposition_with(prompt, &asks, lexicon);
intake.manifest.atomic_asks = asks;
if intake.disposition != PromptDisposition::Ask {
intake.disposition = intake.post_lock_disposition;
}
debug_assert!(intake.validate().is_ok());
intake
}
pub fn resume_with(
nudge: &str,
disposition: PromptDisposition,
lexicon: &DispositionLexicon,
) -> Self {
let mut intake = Self::analyze_with(nudge, lexicon);
if disposition != PromptDisposition::Ask && intake.disposition != PromptDisposition::Ask {
intake.disposition = disposition;
intake.post_lock_disposition = disposition;
}
debug_assert!(intake.validate().is_ok());
intake
}
pub fn analyze(prompt: &str) -> Self {
if prompt.trim().is_empty() {
let intake = Self {
manifest: PromptComprehensionManifest {
atomic_asks: vec![AtomicAsk {
text: "(empty operator prompt)".to_string(),
kind: AskKind::Instruction,
}],
decisions: vec![DecisionLock {
question: "Provide a non-empty task before execution.".to_string(),
status: DecisionStatus::Pending,
source: None,
assumption: None,
overflow: false,
answer: None,
}],
},
disposition: PromptDisposition::Ask,
post_lock_disposition: PromptDisposition::Explain,
source: DispositionSource::Inferred,
last_rejection: None,
pending_discussion: None,
proposed_answer: None,
last_rejection_proposal: None,
};
debug_assert!(intake.validate().is_ok());
return intake;
}
let (atomic_asks, atomic_overflow) = extract_atomic_asks(prompt);
let post_lock_disposition = infer_disposition(prompt, &atomic_asks);
let (mut decisions, decision_overflow) = extract_decisions(&atomic_asks);
if atomic_overflow || decision_overflow {
decisions.push(overflow_decision());
}
let disposition = if decisions
.iter()
.any(|decision| decision.status == DecisionStatus::Pending)
{
PromptDisposition::Ask
} else {
post_lock_disposition
};
let intake = Self {
manifest: PromptComprehensionManifest {
atomic_asks,
decisions,
},
disposition,
post_lock_disposition,
source: DispositionSource::Inferred,
last_rejection: None,
pending_discussion: None,
proposed_answer: None,
last_rejection_proposal: None,
};
debug_assert!(intake.validate().is_ok());
intake
}
pub fn disposition(&self) -> PromptDisposition {
self.disposition
}
pub fn enforce_read_only(&mut self, disposition: PromptDisposition) {
if !matches!(
disposition,
PromptDisposition::Explain | PromptDisposition::Research | PromptDisposition::Plan
) {
debug_assert!(false, "read-only disposition required");
return;
}
if self.disposition != PromptDisposition::Ask {
self.disposition = disposition;
self.post_lock_disposition = disposition;
self.source = DispositionSource::SessionPolicy;
}
debug_assert!(self.validate().is_ok());
}
pub fn disposition_source(&self) -> DispositionSource {
self.source
}
pub fn atomic_asks(&self) -> &[AtomicAsk] {
self.manifest.atomic_asks()
}
pub fn manifest(&self) -> &PromptComprehensionManifest {
&self.manifest
}
pub fn clarification_batch(&self) -> String {
let pending = self.pending_indices();
if pending.is_empty() {
return String::new();
}
let mut rendered = String::from(if pending.len() == 1 {
"I need this decision locked before I can execute. Reply with an explicit ordinal, for example `1: …`:\n"
} else {
"I need these decisions locked before I can execute. Reply using an explicit ordinal for every item, for example `1: …`:\n"
});
for (ordinal, index) in pending.iter().enumerate() {
let decision = &self.manifest.decisions[*index];
let question = truncate_chars(&decision.question, MAX_CLARIFICATION_BYTES);
rendered.push_str(&format!("{}. {}\n", ordinal + 1, question));
}
rendered.push_str(
"(`/discuss <what's on your mind>` talks it through first — nothing here is lost.)\n",
);
rendered.trim_end().to_string()
}
pub fn last_rejection(&self) -> Option<&ClarificationRejection> {
self.last_rejection.as_ref()
}
pub fn take_pending_discussion(&mut self) -> Option<String> {
self.pending_discussion.take()
}
pub fn propose_answer(&self, ordinal: usize, summary: impl Into<String>) -> Self {
let mut proposed = self.clone();
let pending = proposed.pending_indices();
let Some(index) = ordinal.checked_sub(1).and_then(|i| pending.get(i).copied()) else {
return proposed;
};
proposed.proposed_answer = Some(ProposedAnswer {
index,
summary: summary.into(),
});
proposed
}
pub fn proposed_answer_notice(&self) -> Option<String> {
let proposal = self.proposed_answer.as_ref()?;
let ordinal = self.proposal_ordinal(proposal);
Some(format!(
"I read that as choosing {ordinal}: \"{}\" — reply `yes` to lock it, \
or answer explicitly (`{ordinal}: …`) if that's not right.",
proposal.summary
))
}
fn proposal_ordinal(&self, proposal: &ProposedAnswer) -> usize {
self.pending_indices()
.iter()
.position(|&i| i == proposal.index)
.map_or(0, |p| p + 1)
}
pub fn last_rejection_explanation(&self) -> Option<String> {
let rejection = self.last_rejection.as_ref()?;
let mut text = rejection.explain();
if let Some(proposal) = self.last_rejection_proposal.as_ref() {
let ordinal = self.proposal_ordinal(proposal);
text.push_str(&format!(
"\n(the reading I proposed is still waiting — reply `yes` to take it: \
{ordinal}: \"{}\".)",
proposal.summary
));
}
Some(text)
}
pub fn newly_locked_lines(&self, previous: &Self) -> Vec<String> {
previous
.pending_indices()
.iter()
.enumerate()
.filter_map(|(position, &index)| {
let decision = self.manifest.decisions.get(index)?;
(decision.status == DecisionStatus::Locked)
.then(|| format!("locked {}: {}", position + 1, decision.answer_or_question()))
})
.collect()
}
fn pending_indices(&self) -> Vec<usize> {
self.manifest
.decisions
.iter()
.enumerate()
.filter_map(|(index, decision)| {
(decision.status == DecisionStatus::Pending).then_some(index)
})
.collect()
}
pub fn resolve_with_operator_answer(&self, answer: &str) -> Self {
if self
.manifest
.atomic_asks
.first()
.is_some_and(|ask| ask.text == "(empty operator prompt)")
{
return Self::analyze(answer);
}
if self
.manifest
.decisions
.iter()
.any(DecisionLock::is_overflow)
{
return self.clone();
}
let mut resolved = self.clone();
resolved.last_rejection_proposal = None;
let outstanding_proposal = resolved.proposed_answer.clone();
if let Some(proposal) = outstanding_proposal.clone() {
let normalized = answer.trim().to_ascii_lowercase();
if CONFIRMATION_WORDS.contains(&normalized.as_str()) {
resolved.proposed_answer = None;
if let Some(decision) = resolved.manifest.decisions.get_mut(proposal.index) {
decision.status = DecisionStatus::Locked;
decision.source = Some(DecisionSource::Operator);
decision.answer = Some(proposal.summary);
}
resolved.last_rejection = None;
resolved.disposition = if resolved.manifest.pending_decision_count() == 0 {
resolved.post_lock_disposition
} else {
PromptDisposition::Ask
};
debug_assert!(resolved.validate().is_ok());
return resolved;
}
}
if let Some(text) = discussion_request(answer) {
resolved.last_rejection = None;
resolved.pending_discussion = Some(text);
debug_assert!(resolved.validate().is_ok());
return resolved;
}
let pending = resolved.pending_indices();
match explicit_answer_outcome(answer, &pending) {
Ok(indices) => {
resolved.proposed_answer = None;
for (index, value) in indices {
let decision = &mut resolved.manifest.decisions[index];
decision.status = DecisionStatus::Locked;
decision.source = Some(DecisionSource::Operator);
decision.answer = value;
}
resolved.last_rejection = None;
}
Err(rejection) => {
if matches!(
rejection,
ClarificationRejection::NoOrdinals | ClarificationRejection::ReadsAsQuestion
) {
resolved.last_rejection_proposal = outstanding_proposal;
} else {
resolved.proposed_answer = None;
}
resolved.last_rejection = Some(rejection);
}
}
resolved.disposition = if resolved.manifest.pending_decision_count() == 0 {
resolved.post_lock_disposition
} else {
PromptDisposition::Ask
};
debug_assert!(resolved.validate().is_ok());
resolved
}
pub fn adjudication_candidates(&self) -> Vec<AdjudicationCandidate> {
self.candidate_indices()
.into_iter()
.enumerate()
.map(|(ordinal, index)| AdjudicationCandidate {
id: ordinal + 1,
question: self.manifest.decisions[index].question.clone(),
})
.collect()
}
fn candidate_indices(&self) -> Vec<usize> {
self.manifest
.decisions
.iter()
.enumerate()
.filter_map(|(index, decision)| {
(decision.status == DecisionStatus::Pending && !decision.overflow).then_some(index)
})
.collect()
}
pub fn apply_adjudications(
&self,
verdicts: &[AdjudicationVerdict],
) -> Result<Self, AdjudicationRefusal> {
let candidates = self.candidate_indices();
if candidates.len() > MAX_ADJUDICATION_BATCH {
return Err(AdjudicationRefusal::BatchTooLarge {
candidates: candidates.len(),
bound: MAX_ADJUDICATION_BATCH,
});
}
let mut resolved = self.clone();
let mut seen = BTreeSet::new();
for verdict in verdicts {
if !verdict.delegated_to_agent {
continue;
}
let assumption = verdict.assumption.trim();
if assumption.is_empty() {
continue;
}
let Some(index) = verdict
.decision_id
.checked_sub(1)
.and_then(|ordinal| candidates.get(ordinal).copied())
else {
continue;
};
if !seen.insert(index) {
continue;
}
let decision = &mut resolved.manifest.decisions[index];
debug_assert_eq!(decision.status, DecisionStatus::Pending);
decision.status = DecisionStatus::Locked;
decision.source = Some(DecisionSource::AuthorizedAssumption);
decision.assumption = Some(
truncate_chars(assumption, MAX_CLARIFICATION_BYTES)
.trim()
.to_string(),
);
}
resolved.disposition = if resolved.manifest.pending_decision_count() == 0 {
resolved.post_lock_disposition
} else {
PromptDisposition::Ask
};
debug_assert!(resolved.validate().is_ok());
Ok(resolved)
}
fn assumption_indices(&self) -> Vec<usize> {
self.manifest
.decisions
.iter()
.enumerate()
.filter_map(|(index, decision)| {
(decision.source == Some(DecisionSource::AuthorizedAssumption)).then_some(index)
})
.collect()
}
pub fn authorized_assumption_notices(&self) -> Vec<String> {
self.assumption_indices()
.into_iter()
.enumerate()
.filter_map(|(ordinal, index)| {
let assumption = self.manifest.decisions[index].assumption.as_deref()?;
Some(format!(
"Assuming: {assumption} — `/undo-lock {}` to reopen",
ordinal + 1
))
})
.collect()
}
pub fn undo_lock(&self, ordinal: usize) -> Option<Self> {
let index = *self.assumption_indices().get(ordinal.checked_sub(1)?)?;
let mut reopened = self.clone();
let decision = &mut reopened.manifest.decisions[index];
decision.status = DecisionStatus::Pending;
decision.source = None;
decision.assumption = None;
reopened.disposition = PromptDisposition::Ask;
debug_assert!(reopened.validate().is_ok());
Some(reopened)
}
#[cfg(test)]
pub(super) fn with_extra_pending_candidates(&self, extra: usize) -> Self {
let mut widened = self.clone();
for index in 0..extra {
widened.manifest.decisions.push(DecisionLock {
question: format!("Choose the smallest fix for extra-module-{index}."),
status: DecisionStatus::Pending,
source: None,
assumption: None,
overflow: false,
answer: None,
});
}
widened.disposition = PromptDisposition::Ask;
widened
}
pub fn authorized_assumption_count(&self) -> usize {
self.assumption_indices().len()
}
pub fn model_card(&self) -> String {
let pending = self.manifest.pending_decision_count();
let locked = self.manifest.locked_decision_count();
let instruction =
super::DispositionVoices::default().card_block(self.disposition, self.source);
let prompt = self
.manifest
.atomic_asks
.iter()
.map(AtomicAsk::text)
.collect::<Vec<_>>()
.join("\n");
let refinement = request_refinement_model_card(&prompt);
let mut card = format!(
"{PROMPT_COMPREHENSION_MODEL_CARD_PREFIX}\n\
disposition: {}\n\
atomic_ask_count: {}\n\
decision_count: {}\n\
pending_decision_count: {pending}\n\
locked_decision_count: {locked}\n\
{instruction}",
self.disposition.as_str(),
self.manifest.atomic_asks.len(),
self.manifest.decisions.len(),
);
if !refinement.is_empty() {
card.push('\n');
card.push_str(&refinement);
}
let noted: Vec<&str> = self
.manifest
.informational_asks()
.map(AtomicAsk::text)
.collect();
if !noted.is_empty() {
card.push_str(
"\nnoted_facts: the operator STATED the following; they carry no request to act\n",
);
for fact in noted {
card.push_str(" - ");
card.push_str(fact);
card.push('\n');
}
card.push_str(
"noted_instruction: acknowledge these in your reply and carry them forward; \
do not treat them as authorization to mutate anything",
);
}
card
}
pub fn artifact_metadata(&self) -> Value {
let mut status_counts = serde_json::Map::new();
status_counts.insert(
DecisionStatus::Pending.as_str().to_string(),
Value::from(self.manifest.pending_decision_count() as u64),
);
status_counts.insert(
DecisionStatus::Locked.as_str().to_string(),
Value::from(self.manifest.locked_decision_count() as u64),
);
let mut source_counts = serde_json::Map::new();
for source in [
DecisionSource::Operator,
DecisionSource::Policy,
DecisionSource::AuthorizedAssumption,
] {
let count = self
.manifest
.decisions
.iter()
.filter(|decision| decision.source == Some(source))
.count();
source_counts.insert(source.as_str().to_string(), Value::from(count as u64));
}
json!({
"schema": PROMPT_COMPREHENSION_SCHEMA_CURRENT,
"disposition": self.disposition.as_str(),
"atomic_ask_count": self.manifest.atomic_asks.len() as u64,
"clarification_count": self.manifest.pending_decision_count() as u64,
"decision_count": self.manifest.decisions.len() as u64,
"decision_status_counts": Value::Object(status_counts),
"decision_source_counts": Value::Object(source_counts),
"informational_ask_count": self.manifest.informational_asks().count() as u64,
"informational_asks": self
.manifest
.informational_asks()
.map(|ask| Value::from(ask.text()))
.collect::<Vec<_>>(),
"atomic_ask_kinds": self
.manifest
.atomic_asks
.iter()
.map(|ask| Value::from(ask.kind().as_str()))
.collect::<Vec<_>>(),
"atomic_ask_digests": self
.manifest
.atomic_asks
.iter()
.map(|ask| digest_metadata(&ask.text))
.collect::<Vec<_>>(),
"clarification_digests": self
.manifest
.decisions
.iter()
.filter(|decision| decision.status == DecisionStatus::Pending)
.map(|decision| digest_metadata(&decision.question))
.collect::<Vec<_>>(),
"authorized_assumption_digests": self
.manifest
.decisions
.iter()
.filter_map(|decision| decision.assumption.as_deref())
.map(digest_metadata)
.collect::<Vec<_>>(),
})
}
pub fn validate(&self) -> Result<(), String> {
if self.manifest.atomic_asks.len() > MAX_ATOMIC_ASKS {
return Err("atomic ask count exceeds the intake bound".to_string());
}
if self.manifest.decisions.len() > MAX_DECISIONS {
return Err("decision count exceeds the intake bound".to_string());
}
if self.post_lock_disposition == PromptDisposition::Ask {
return Err("post-lock disposition cannot be ask".to_string());
}
for ask in &self.manifest.atomic_asks {
if ask.text.is_empty() || ask.text.len() > MAX_ASK_BYTES {
return Err("atomic ask is empty or exceeds the intake bound".to_string());
}
}
for decision in &self.manifest.decisions {
if decision.question.is_empty() || decision.question.len() > MAX_ASK_BYTES {
return Err("decision text is empty or exceeds the intake bound".to_string());
}
match (decision.status, decision.source) {
(DecisionStatus::Pending, None) | (DecisionStatus::Locked, Some(_)) => {}
(DecisionStatus::Pending, Some(_)) => {
return Err("pending decision has a lock source".to_string())
}
(DecisionStatus::Locked, None) => {
return Err("locked decision lacks a source".to_string())
}
}
match (decision.source, decision.assumption.as_deref()) {
(Some(DecisionSource::AuthorizedAssumption), None | Some("")) => {
return Err("authorized assumption lock lacks a stated assumption".to_string())
}
(Some(DecisionSource::AuthorizedAssumption), Some(_)) => {}
(_, Some(_)) => {
return Err(
"only an authorized assumption may carry assumption text".to_string()
)
}
(_, None) => {}
}
}
let pending = self.manifest.pending_decision_count();
if (pending > 0) != (self.disposition == PromptDisposition::Ask) {
return Err("disposition does not match pending decisions".to_string());
}
if pending == 0 && self.disposition != self.post_lock_disposition {
return Err("resolved disposition does not match task disposition".to_string());
}
Ok(())
}
}
fn classify_clause(clause: &str, lexicon: &DispositionLexicon) -> AskKind {
let lower = clause.trim().to_ascii_lowercase();
let opens_with = |needles: &[String]| {
needles
.iter()
.any(|n| !n.is_empty() && lower.starts_with(n.as_str()))
};
if opens_with(&lexicon.informational_markers) {
return AskKind::Informational;
}
let padded = format!(" {lower} ");
let stative = lexicon
.stative_markers
.iter()
.any(|m| !m.is_empty() && padded.contains(m.as_str()));
if stative && opens_with(&lexicon.declarative_leads) {
return AskKind::Informational;
}
AskKind::Instruction
}
fn extract_atomic_asks(prompt: &str) -> (Vec<AtomicAsk>, bool) {
extract_atomic_asks_with(prompt, &DispositionLexicon::default())
}
fn extract_atomic_asks_with(prompt: &str, lexicon: &DispositionLexicon) -> (Vec<AtomicAsk>, bool) {
let mut asks = Vec::new();
for line in prompt.lines() {
let line = strip_list_marker(line.trim());
if line.is_empty() {
continue;
}
for semicolon_clause in line.split(';') {
for clause in semicolon_clause.split(". ") {
let clause = clause.trim();
if clause.is_empty() {
continue;
}
if asks.len() == MAX_ATOMIC_ASKS {
return (asks, true);
}
asks.push(AtomicAsk {
text: truncate_chars(clause, MAX_ASK_BYTES),
kind: classify_clause(clause, lexicon),
});
}
}
}
if asks.is_empty() {
asks.push(AtomicAsk {
text: "(empty operator prompt)".to_string(),
kind: AskKind::Instruction,
});
}
(asks, false)
}
fn strip_list_marker(line: &str) -> &str {
let line = line.trim_start_matches(['-', '*', '•', ' ']);
let digits = line.bytes().take_while(u8::is_ascii_digit).count();
if digits > 0 {
let rest = &line[digits..];
if let Some(rest) = rest.strip_prefix(['.', ')']) {
return rest.trim_start();
}
}
line
}
fn request_refinement_model_card(prompt: &str) -> String {
let lower = prompt.to_ascii_lowercase();
let packs = crate::api_surface::builtin_packs();
let language = crate::api_surface::detect_source_language(prompt, &packs);
let contains = |needle| crate::api_surface::contains_bounded_ascii(&lower, needle);
let names_source_files = ["code file", "code files", "source file", "source files"]
.iter()
.any(|needle| contains(needle));
let names_language_files = language.is_some()
&& ["file", "files", "script", "scripts"]
.iter()
.any(|needle| contains(needle));
let source_files = names_source_files || names_language_files;
let mut lines = Vec::new();
if contains("table") {
lines.push("response_shape: table".to_string());
}
if source_files {
lines.push("evidence_scope: source_files".to_string());
match language {
Some(pack) => {
if let Ok(extensions) =
crate::api_surface::source_extensions_for(&packs, Some(&pack.name))
{
lines.push(format!("source_extensions: {}", extensions.join(",")));
}
lines.push(format!(
"source_filter: category=source language={}",
pack.name
));
}
None => lines.push("source_filter: category=source".to_string()),
}
lines.push(
"scope_instruction: code/source means registered language source files only; \
exclude documentation, manifests, lockfiles, and other repository metadata \
from the primary evidence set"
.to_string(),
);
}
lines.join("\n")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DispositionLexicon {
pub action: Vec<String>,
pub research: Vec<String>,
pub explain: Vec<String>,
pub question_mark_disposition: PromptDisposition,
pub request_openers: Vec<String>,
pub collaborative: Vec<String>,
pub direct_verbs: Vec<String>,
pub informational_markers: Vec<String>,
pub declarative_leads: Vec<String>,
pub stative_markers: Vec<String>,
}
impl Default for DispositionLexicon {
fn default() -> Self {
Self {
action: [
"implement",
"modify",
"change",
"create",
"write",
"edit",
"delete",
"fix",
"refactor",
"build",
"run ",
"execute",
" test \"",
" test '",
"commit",
"push",
"open a pr",
"open pr",
"merge",
"needs fixing",
"need fixing",
"needs refactoring",
"need refactoring",
"get it fixed",
"get them fixed",
"get it committed",
"rerun the",
"re-run the",
"please rerun",
"please re-run",
]
.map(str::to_string)
.to_vec(),
research: [
"research",
"investigate",
"look up",
"find out",
"analyze",
"diagnose",
"audit",
"explore",
"compare",
"largest",
"biggest",
"smallest",
"line count",
"line counts",
"most lines",
"fewest lines",
"longest file",
"shortest file",
"researching",
"auditing",
"investigating",
"analyzing",
"diagnosing",
"exploring",
"comparing",
]
.map(str::to_string)
.to_vec(),
explain: [
"explain",
"summarize",
"describe",
"what is",
"why ",
"how does",
"how do",
"what are",
"which are",
"tell me",
"explaining",
"summarizing",
"describing",
]
.map(str::to_string)
.to_vec(),
question_mark_disposition: PromptDisposition::Explain,
request_openers: [
"can you ",
"could you ",
"would you ",
"will you ",
"try ",
"retry",
]
.map(str::to_string)
.to_vec(),
collaborative: [
"let's ",
"lets ",
"let’s ",
"we should ",
"how about we ",
"i'm thinking ",
"i’m thinking ",
"i am thinking ",
"shall we ",
]
.map(str::to_string)
.to_vec(),
direct_verbs: [
"commit", "push", "pull", "merge", "rebase", "run", "rerun", "test", "build",
"deploy", "release", "tag", "install", "lint", "format", "check", "land", "ship",
"publish", "bump", "revert", "retry", "start", "stop", "restart", "open", "close",
]
.map(str::to_string)
.to_vec(),
informational_markers: [
"fyi",
"btw",
"by the way",
"just so you know",
"heads up",
"note that",
"for reference",
"for context",
"i'll want",
"i will want",
"i'm going to",
"i am going to",
"we're going to",
"we are going to",
]
.map(str::to_string)
.to_vec(),
declarative_leads: [
"the ", "a ", "an ", "this ", "that ", "these ", "those ", "it ", "its ", "it's ",
"i ", "i'm ", "i've ", "i'll ", "we ", "we're ", "we've ", "we'll ", "you ",
"your ", "there ", "my ", "our ", "their ", "his ", "her ",
]
.map(str::to_string)
.to_vec(),
stative_markers: [
" is ",
" are ",
" was ",
" were ",
" isn't ",
" aren't ",
" will be ",
" has ",
" have ",
" had ",
"'s ",
"'re ",
"'ve ",
]
.map(str::to_string)
.to_vec(),
}
}
}
fn contains_word(haystack: &str, needle: &str) -> bool {
if needle.is_empty() {
return false;
}
let is_word = |c: char| c.is_alphanumeric() || c == '_';
let mut start = 0;
while let Some(pos) = haystack[start..].find(needle) {
let abs = start + pos;
let end = abs + needle.len();
let left_ok = !needle.chars().next().is_some_and(is_word)
|| !haystack[..abs].chars().next_back().is_some_and(is_word);
let right_ok = !needle.chars().next_back().is_some_and(is_word)
|| !haystack[end..].chars().next().is_some_and(is_word);
if left_ok && right_ok {
return true;
}
start = abs + 1;
}
false
}
fn infer_disposition(prompt: &str, asks: &[AtomicAsk]) -> PromptDisposition {
infer_disposition_with(prompt, asks, &DispositionLexicon::default())
}
fn infer_disposition_with(
prompt: &str,
asks: &[AtomicAsk],
lexicon: &DispositionLexicon,
) -> PromptDisposition {
let proposes = |ask: &AtomicAsk| {
let text = ask.text.trim_start().to_ascii_lowercase();
lexicon
.collaborative
.iter()
.filter(|n| !n.is_empty())
.find_map(|n| text.strip_prefix(n.as_str()))
.is_some_and(|rest| {
let verb = rest
.split_whitespace()
.next()
.map(|w| w.trim_matches(|c: char| !c.is_alphanumeric()))
.unwrap_or("");
!crate::classifiers::is_bare_continuation(rest)
&& !lexicon.direct_verbs.iter().any(|v| v == verb)
})
};
if !asks.is_empty() && asks.iter().all(proposes) {
return PromptDisposition::Plan;
}
let lower = prompt.to_ascii_lowercase();
let padded = format!(" {lower}");
let hit = |needles: &[String]| {
needles
.iter()
.any(|n| !n.is_empty() && contains_word(&padded, n))
};
if hit(&lexicon.action) {
return PromptDisposition::Act;
}
if hit(&lexicon.research) {
return PromptDisposition::Research;
}
if hit(&lexicon.explain) {
return PromptDisposition::Explain;
}
let requests = |ask: &AtomicAsk| {
let text = ask.text.trim_start().to_ascii_lowercase();
lexicon
.request_openers
.iter()
.any(|n| !n.is_empty() && text.starts_with(n.as_str()))
};
if lower.trim_end().ends_with('?') && !asks.iter().any(requests) {
return lexicon.question_mark_disposition;
}
if !asks.is_empty() && asks.iter().all(AtomicAsk::is_informational) {
return PromptDisposition::Explain;
}
PromptDisposition::Act
}
fn extract_decisions(asks: &[AtomicAsk]) -> (Vec<DecisionLock>, bool) {
let mut decisions = Vec::new();
for ask in asks {
let lower = ask.text.to_ascii_lowercase();
let decision =
if needs_operator_decision(&lower) || has_ambiguous_destructive_target(&lower) {
Some((DecisionStatus::Pending, None))
} else {
None
};
if let Some((status, source)) = decision {
if decisions.len() == MAX_CONCRETE_DECISIONS {
return (decisions, true);
}
decisions.push(DecisionLock {
question: ask.text.clone(),
status,
source,
assumption: None,
overflow: false,
answer: None,
});
}
}
(decisions, false)
}
fn overflow_decision() -> DecisionLock {
DecisionLock {
question: format!(
"This request exceeds Newt's bounded intake capacity ({MAX_CONCRETE_DECISIONS} decisions or {MAX_ATOMIC_ASKS} asks). Use /new, then start a smaller task before execution."
),
status: DecisionStatus::Pending,
source: None,
assumption: None,
overflow: true,
answer: None,
}
}
const NEGATION_CUES: &[&str] = &[
"do not ",
"don't ",
"must not ",
"must never ",
"should not ",
"shouldn't ",
"never ",
"avoid ",
];
const DIRECTIVE_SUBJECT_PREFIXES: &[&str] = &["please ", "you "];
fn strip_leading_label(s: &str) -> &str {
if let Some(colon) = s.find(':') {
let (label, rest) = s.split_at(colon);
if (2..=20).contains(&label.len())
&& label.chars().all(|c| c.is_ascii_alphabetic())
&& rest.starts_with(": ")
{
return rest[": ".len()..].trim_start();
}
}
s
}
fn is_directive_prohibition(lower: &str) -> bool {
if lower.contains('?') {
return false;
}
let mut clause = strip_leading_label(lower.trim_start());
while let Some(rest) = DIRECTIVE_SUBJECT_PREFIXES
.iter()
.find_map(|prefix| clause.strip_prefix(prefix))
{
clause = rest;
}
NEGATION_CUES.iter().any(|cue| clause.starts_with(cue))
}
fn needs_operator_decision(lower: &str) -> bool {
let prohibition = is_directive_prohibition(lower);
(!prohibition && contains_any(lower, &["choose ", "select ", "pick "]))
|| contains_any(
lower,
&[
"either ",
"tbd",
"to be decided",
"which option",
"which backend",
"which provider",
],
)
|| (!prohibition
&& lower.contains(" or ")
&& contains_any(lower, &["should", "use", "implement"]))
}
fn has_ambiguous_destructive_target(lower: &str) -> bool {
[
"delete it",
"delete this",
"delete that",
"remove it",
"remove this",
"remove that",
"drop it",
"drop this",
"drop that",
"destroy it",
"destroy this",
"destroy that",
"wipe it",
"wipe this",
"wipe that",
"purge it",
"purge this",
"purge that",
]
.iter()
.any(|phrase| lower.contains(phrase))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ClarificationRejection {
NoOrdinals,
Incomplete { answered: usize, expected: usize },
OutOfRange { ordinal: usize, expected: usize },
ReadsAsQuestion,
}
impl ClarificationRejection {
pub fn explain(&self) -> String {
let detail = match self {
Self::NoOrdinals => {
"no `N: value` line was found — each answer needs its ordinal, like `1: use the second option`".to_string()
}
Self::Incomplete { answered, expected } => format!(
"{answered} of {expected} decisions were answered — locking is all-or-nothing, so answer every ordinal in one reply"
),
Self::OutOfRange { ordinal, expected } => format!(
"ordinal {ordinal} is outside this batch — the pending items are numbered 1..={expected}"
),
Self::ReadsAsQuestion => {
"the reply read as a question rather than an answer — prefix each answer with its ordinal (`1: …`) and it will be accepted even if it contains a `?`".to_string()
}
};
format!(
"that reply did not lock the batch: {detail}.\n\
(`/discuss <what's on your mind>` talks it through without losing the batch; \
`/new` abandons this prompt and starts a fresh conversation.)"
)
}
}
const ORDINAL_SEPARATORS: [char; 3] = [':', '.', ')'];
fn explicit_answer_outcome(
answer: &str,
pending: &[usize],
) -> Result<Vec<(usize, Option<String>)>, ClarificationRejection> {
let answer = answer.trim();
if answer.is_empty() {
return Err(ClarificationRejection::NoOrdinals);
}
let mut resolved = BTreeMap::new();
let mut saw_ordinal = false;
let mut out_of_range = None;
for line in answer.lines() {
let line = line.trim();
let line = line.strip_prefix("decision ").unwrap_or(line);
let Some((ordinal, value)) = ORDINAL_SEPARATORS.iter().find_map(|sep| {
let (ordinal, value) = line.split_once(*sep)?;
if *sep == '.' && value.starts_with(|c: char| c.is_ascii_digit()) {
return None;
}
Some((ordinal, value))
}) else {
continue;
};
let Ok(ordinal) = ordinal.trim().parse::<usize>() else {
continue;
};
saw_ordinal = true;
let value = value.trim();
let value = (!value.is_empty()).then(|| value.to_string());
let Some(pending_ordinal) = ordinal.checked_sub(1) else {
out_of_range = Some(ordinal);
continue;
};
match pending.get(pending_ordinal) {
Some(index) => {
resolved.insert(*index, value);
}
None => out_of_range = Some(ordinal),
}
}
if !saw_ordinal {
if looks_like_unresolved_question(answer) {
return Err(ClarificationRejection::ReadsAsQuestion);
}
return Err(ClarificationRejection::NoOrdinals);
}
if let Some(ordinal) = out_of_range {
if resolved.len() != pending.len() {
return Err(ClarificationRejection::OutOfRange {
ordinal,
expected: pending.len(),
});
}
}
if resolved.len() != pending.len() {
return Err(ClarificationRejection::Incomplete {
answered: resolved.len(),
expected: pending.len(),
});
}
Ok(resolved.into_iter().collect())
}
#[cfg(test)]
fn explicit_answer_indices(answer: &str, pending: &[usize]) -> Option<Vec<usize>> {
explicit_answer_outcome(answer, pending)
.ok()
.map(|resolved| resolved.into_iter().map(|(index, _value)| index).collect())
}
pub fn discussion_request(answer: &str) -> Option<String> {
let rest = answer
.strip_prefix("/discuss")
.or_else(|| answer.strip_prefix("/chat"))?;
if !rest.is_empty() && !rest.starts_with(char::is_whitespace) {
return None;
}
Some(rest.trim().to_string())
}
fn looks_like_unresolved_question(answer: &str) -> bool {
let lower = answer.to_ascii_lowercase();
answer.contains('?')
|| lower.starts_with("what ")
|| lower.starts_with("which ")
|| lower.starts_with("can ")
|| lower.starts_with("could ")
|| lower.starts_with("should ")
}
fn contains_any(text: &str, needles: &[&str]) -> bool {
needles.iter().any(|needle| text.contains(needle))
}
fn truncate_chars(text: &str, byte_limit: usize) -> String {
if text.len() <= byte_limit {
return text.to_string();
}
let mut end = byte_limit;
while !text.is_char_boundary(end) {
end -= 1;
}
text[..end].to_string()
}
fn digest_metadata(text: &str) -> Value {
json!({
"digest": blake3::hash(text.as_bytes()).to_hex().to_string(),
"bytes": text.len() as u64,
})
}
#[cfg(test)]
#[path = "prompt_intake_tests/mod.rs"]
mod prompt_intake_tests;