mod checkpoint;
mod completion_audit;
mod completion_gate_flow;
pub(crate) mod completion_gate_panel;
mod cycle_band;
mod cycles;
mod deliverable_manifest;
mod gate_telemetry;
mod generic_gate;
mod go_toolchain_audit;
mod graph;
pub(crate) mod handoff;
mod integration_gate;
pub(crate) mod macro_loop;
pub(crate) mod macro_loop_panel;
mod manifest_gate;
mod nudge;
mod objective;
mod plan_drift;
pub(crate) mod progress;
mod reinject;
pub(crate) mod snapshots;
mod stub_gate;
mod task_graph;
mod verify;
mod verify_platform;
pub use checkpoint::tool_marks_lht_checkpoint;
pub use cycle_band::{context_pressure_ratio, in_lht_warning_band, should_lht_early_advance_cycle};
pub use cycles::build_cycles_value;
pub(crate) use nudge::VERIFICATION_RE;
pub use reinject::{build_objective_reinject_message, should_reinject_this_step};
pub(crate) use verify::verify_gate_verdict;
pub use completion_gate_panel::CompletionGatePanelJson;
pub use graph::CodeTaskGraph;
pub use handoff::{build_lht_handoff_section, merge_lht_into_handoff};
pub use manifest_gate::CompletionGateExec;
pub use nudge::{
LongHorizonSessionState, NudgeDecision, build_auto_continue_message, build_nudge_message,
};
pub use objective::derive_objective;
pub use task_graph::{
TaskGraphTelemetryJson, build_task_graph_value, build_task_graph_value_with_telemetry,
};
use std::path::Path;
use zagens_core::chat::{ContentBlock, Message};
use zagens_core::long_horizon::{CompletionGateMode, GenericGateMode, LhtMode, LongHorizonConfig};
use zagens_core::scratchpad::ScratchpadConfig;
use zagens_core::task_type::TaskType;
use crate::agent_surface::AppMode;
use crate::tools::plan::SharedPlanState;
use crate::tools::todo::SharedTodoList;
pub struct LongHorizonContinueInput<'a> {
pub config: &'a LongHorizonConfig,
pub lht_mode_override: Option<LhtMode>,
pub scratchpad: &'a ScratchpadConfig,
pub task_type: TaskType,
pub app_mode: AppMode,
pub workspace: &'a Path,
pub scratchpad_run_id: Option<&'a str>,
pub messages: &'a [Message],
pub lang: &'a str,
pub plan_state: &'a SharedPlanState,
pub todos: &'a SharedTodoList,
pub session: &'a mut LongHorizonSessionState,
pub thread_id: &'a str,
pub already_injected_this_turn: bool,
pub steps_remaining: u32,
pub gate_exec: Option<CompletionGateExec<'a>>,
}
fn audit_scratchpad_blocks_lht(
workspace: &Path,
run_id: Option<&str>,
scratchpad: &ScratchpadConfig,
messages: &[Message],
) -> bool {
crate::core::engine::scratchpad_flow::maybe_continue_incomplete_audit(
workspace, run_id, scratchpad, messages,
)
.is_some()
}
fn count_tool_uses(messages: &[Message]) -> usize {
messages
.iter()
.flat_map(|m| m.content.iter())
.filter(|b| matches!(b, ContentBlock::ToolUse { .. }))
.count()
}
fn evaluate_plan_bootstrap(
messages: &[Message],
session: &mut LongHorizonSessionState,
lang: &str,
) -> Option<LhtGateOutcome> {
if count_tool_uses(messages) < nudge::MIN_TOOL_USES_FOR_PLAN_GATE {
return None; }
if session.plan_gate_rounds >= nudge::MAX_PLAN_GATE_ROUNDS {
session
.pending_gate_events
.push(gate_telemetry::CompletionGateEvent::plan_gate(
false,
session.plan_gate_rounds,
));
return None;
}
session.plan_gate_rounds += 1;
session
.pending_gate_events
.push(gate_telemetry::CompletionGateEvent::plan_gate(
true,
session.plan_gate_rounds,
));
Some(LhtGateOutcome::NudgePlanRequired(Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: nudge::build_plan_required_nudge(lang),
cache_control: None,
}],
}))
}
fn strict_completion_gate(
base: &zagens_core::long_horizon::CompletionGateConfig,
mode: LhtMode,
) -> zagens_core::long_horizon::CompletionGateConfig {
let mut gate = base.clone();
if mode.is_strict() {
if gate.mode != CompletionGateMode::Enforce {
gate.mode = CompletionGateMode::Enforce;
}
if !gate.stub_gate.is_enforce() {
gate.stub_gate = GenericGateMode::Enforce;
}
if gate.auto_verify_replay.is_on() && !gate.auto_verify_replay.is_enforce() {
gate.auto_verify_replay = GenericGateMode::Enforce;
}
if gate.toolchain_gate.is_on() && !gate.toolchain_gate.is_enforce() {
gate.toolchain_gate = GenericGateMode::Enforce;
}
}
gate
}
pub enum LhtGateOutcome {
Nudge(Message),
NudgeUnverifiedAcceptance(Message),
NudgeVerifyMismatch(Message),
NudgeInsufficientVerify(Message),
NudgePlanChecklistDrift(Message),
NudgeIntegrationIncomplete(Message),
NudgeManifestFailed(Message),
NudgeStubsFound(Message),
NudgePlanRequired(Message),
NudgeDeliverablesMissing(Message),
ObserveManifestGate {
failing_gate_ids: Vec<String>,
audit: Option<completion_audit::CompletionAuditResult>,
},
AuditUnmet {
reason: &'static str,
failing_gates: Vec<String>,
missing_deliverable_ids: Vec<String>,
manifest_round: u32,
audit_round: u32,
first_gap_count: Option<u32>,
},
MacroCraftSpawn {
task_id: String,
},
MacroRemediation(Message),
MacroUnmet {
remaining_blockers: Vec<String>,
macro_cycles_used: u32,
},
Skip(&'static str),
}
pub async fn maybe_continue_incomplete_code_task(
input: LongHorizonContinueInput<'_>,
) -> LhtGateOutcome {
if !input.config.enabled {
return LhtGateOutcome::Skip("disabled");
}
if input.already_injected_this_turn {
return LhtGateOutcome::Skip("already_injected_this_turn");
}
if input.session.paused {
return LhtGateOutcome::Skip("session_paused");
}
if !input.task_type.uses_code_tool_surface() {
return LhtGateOutcome::Skip("not_code_task");
}
if input.app_mode == AppMode::Plan {
return LhtGateOutcome::Skip("plan_mode");
}
if audit_scratchpad_blocks_lht(
input.workspace,
input.scratchpad_run_id,
input.scratchpad,
input.messages,
) {
return LhtGateOutcome::Skip("audit_owns_path");
}
let effective_mode = input.lht_mode_override.unwrap_or(input.config.mode);
let plan = input.plan_state.lock().await.snapshot();
let checklist = input.todos.lock().await.snapshot();
let mut graph = CodeTaskGraph::from_snapshots(&plan, &checklist);
if graph.is_empty() {
if effective_mode.is_strict()
&& let Some(outcome) =
evaluate_plan_bootstrap(input.messages, &mut *input.session, input.lang)
{
return outcome;
}
return LhtGateOutcome::Skip("graph_empty");
}
if !graph.incomplete() {
let unverified: Vec<String> = checklist
.items
.iter()
.filter(|i| i.status == crate::tools::todo::TodoStatus::Completed)
.filter(|i| {
verify::verify_gate_verdict(
&i.content,
&input.session.recent_verification_cmds,
input.lang,
)
.0 == "unverified_acceptance"
})
.map(|i| verify::strip_verify_prefix(&i.content))
.collect();
if !unverified.is_empty()
&& input.session.unverified_acceptance_nudges < nudge::MAX_UNVERIFIED_ACCEPTANCE_NUDGES
{
input.session.unverified_acceptance_nudges += 1;
let text = nudge::build_unverified_acceptance_nudge(&unverified, input.lang);
return LhtGateOutcome::NudgeUnverifiedAcceptance(Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text,
cache_control: None,
}],
});
}
let mismatched: Vec<(String, String)> = checklist
.items
.iter()
.filter(|i| i.status == crate::tools::todo::TodoStatus::Completed)
.filter_map(|i| {
if verify::verify_gate_verdict(
&i.content,
&input.session.recent_verification_cmds,
input.lang,
)
.0 != "mismatch"
{
return None;
}
verify::parse_verify_command(&i.content)
.map(|cmd| (verify::strip_verify_prefix(&i.content), cmd))
})
.collect();
if !mismatched.is_empty()
&& input.session.verify_mismatch_nudges < nudge::MAX_VERIFY_MISMATCH_NUDGES
{
input.session.verify_mismatch_nudges += 1;
let text = nudge::build_verify_mismatch_nudge(&mismatched, input.lang);
return LhtGateOutcome::NudgeVerifyMismatch(Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text,
cache_control: None,
}],
});
}
let completed_count = checklist
.items
.iter()
.filter(|i| i.status == crate::tools::todo::TodoStatus::Completed)
.count();
let verify_tagged_count = checklist
.items
.iter()
.filter(|i| i.status == crate::tools::todo::TodoStatus::Completed)
.filter(|i| verify::parse_verify_command(&i.content).is_some())
.count();
if completed_count >= nudge::MIN_CHECKLIST_ITEMS_FOR_VERIFY_RATIO
&& verify_tagged_count < nudge::MIN_VERIFY_TAGGED_ITEMS
&& input.session.insufficient_verify_nudges < nudge::MAX_INSUFFICIENT_VERIFY_NUDGES
{
input.session.insufficient_verify_nudges += 1;
let text = nudge::build_insufficient_verify_nudge(completed_count, input.lang);
return LhtGateOutcome::NudgeInsufficientVerify(Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text,
cache_control: None,
}],
});
}
let drift = plan_drift::find_plan_checklist_drift(&plan, &checklist);
if !drift.is_empty()
&& input.session.plan_checklist_drift_nudges < nudge::MAX_PLAN_CHECKLIST_DRIFT_NUDGES
{
input.session.plan_checklist_drift_nudges += 1;
let text = nudge::build_plan_checklist_drift_nudge(&drift, input.lang);
return LhtGateOutcome::NudgePlanChecklistDrift(Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text,
cache_control: None,
}],
});
}
let effective_gate = strict_completion_gate(&input.config.completion_gate, effective_mode);
let gate_outcome = completion_gate_flow::evaluate_completion_gate(
input.workspace,
&effective_gate,
&checklist,
input.session,
input.lang,
input.steps_remaining,
input.gate_exec.as_ref(),
)
.await;
let latest_user = latest_user_text(input.messages);
if matches!(&gate_outcome, LhtGateOutcome::Skip("graph_complete")) {
let macro_outcome = macro_loop::evaluate_macro_loop(
macro_loop::MacroLoopInput {
config: &input.config.macro_loop,
effective_mode,
workspace: input.workspace,
checklist: &checklist,
session: input.session,
lang: input.lang,
thread_id: input.thread_id,
latest_user_text: latest_user,
},
macro_loop::MacroTrigger::MicroPass,
);
return macro_loop::macro_outcome_to_gate(macro_outcome);
}
if let LhtGateOutcome::AuditUnmet { reason, .. } = &gate_outcome {
nudge::capture_manifest_gate_hints(input.session);
let macro_outcome = macro_loop::evaluate_macro_loop(
macro_loop::MacroLoopInput {
config: &input.config.macro_loop,
effective_mode,
workspace: input.workspace,
checklist: &checklist,
session: input.session,
lang: input.lang,
thread_id: input.thread_id,
latest_user_text: latest_user,
},
macro_loop::MacroTrigger::AuditUnmet { reason },
);
if !matches!(macro_outcome, macro_loop::MacroLoopOutcome::Inactive) {
return macro_loop::macro_outcome_to_gate(macro_outcome);
}
}
return gate_outcome;
}
if graph.is_trivial() {
return LhtGateOutcome::Skip("graph_trivial");
}
let (objective, source) = derive_objective(&plan, &checklist, input.messages, input.lang);
graph.objective = objective;
graph.objective_source = source;
let stale = input.session.stale_assistant_turns >= nudge::STALE_ASSISTANT_TURNS;
let current_git_signature = if input.config.progress_via_git {
let ws = input.workspace.to_path_buf();
tokio::task::spawn_blocking(move || progress::workspace_change_signature(&ws))
.await
.ok()
.flatten()
} else {
None
};
let git_progress = progress::git_counts_as_progress(
input.session,
current_git_signature.as_ref(),
input.session.last_nudge_git_signature.as_ref(),
);
let had_progress = input.session.progress_since_last_nudge || git_progress;
input.session.progress_since_last_nudge = false;
if had_progress && input.session.awaiting_nudge_outcome {
input.session.telemetry.converted += 1;
input.session.awaiting_nudge_outcome = false;
}
let was_blocked = input.session.tracker.is_blocked();
let decision =
input
.session
.tracker
.prepare_nudge(graph.in_progress_id, input.config, had_progress);
match decision {
NudgeDecision::Skip => return LhtGateOutcome::Skip("nudge_skip"),
NudgeDecision::MaxReached => return LhtGateOutcome::Skip("nudge_max_reached"),
NudgeDecision::Blocked => {
if !was_blocked {
input.session.telemetry.blocked += 1;
}
return LhtGateOutcome::Skip("nudge_blocked");
}
NudgeDecision::Nudge { .. } => {}
}
let turn_limit_warning = input.steps_remaining <= 3;
let text = build_nudge_message(
&graph,
&graph.objective,
input.lang,
turn_limit_warning,
stale,
);
input.session.last_nudge_git_signature = current_git_signature;
input.session.telemetry.emitted += 1;
input.session.awaiting_nudge_outcome = true;
LhtGateOutcome::Nudge(Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text,
cache_control: None,
}],
})
}
fn latest_user_text(messages: &[Message]) -> Option<&str> {
messages
.iter()
.rev()
.find(|m| m.role == "user")
.and_then(|m| {
m.content.iter().find_map(|b| match b {
ContentBlock::Text { text, .. } => Some(text.as_str()),
_ => None,
})
})
}
#[cfg(test)]
mod plan_bootstrap_tests {
use super::*;
fn tool_use_msg(id: &str) -> Message {
Message {
role: "assistant".to_string(),
content: vec![ContentBlock::ToolUse {
id: id.to_string(),
name: "read_file".to_string(),
input: serde_json::json!({}),
caller: None,
}],
}
}
fn text_msg(t: &str) -> Message {
Message {
role: "assistant".to_string(),
content: vec![ContentBlock::Text {
text: t.to_string(),
cache_control: None,
}],
}
}
#[test]
fn count_tool_uses_only_counts_tool_use_blocks() {
let msgs = vec![text_msg("hi"), tool_use_msg("a"), tool_use_msg("b")];
assert_eq!(count_tool_uses(&msgs), 2);
}
#[test]
fn too_few_tool_uses_does_not_force_plan() {
let mut session = LongHorizonSessionState::default();
let msgs = vec![tool_use_msg("a")];
assert!(evaluate_plan_bootstrap(&msgs, &mut session, "en").is_none());
assert_eq!(session.plan_gate_rounds, 0);
}
#[test]
fn real_work_without_plan_forces_plan_then_gives_up() {
let mut session = LongHorizonSessionState::default();
let msgs: Vec<Message> = (0..nudge::MIN_TOOL_USES_FOR_PLAN_GATE)
.map(|i| tool_use_msg(&format!("t{i}")))
.collect();
for round in 1..=nudge::MAX_PLAN_GATE_ROUNDS {
let out = evaluate_plan_bootstrap(&msgs, &mut session, "en");
assert!(matches!(out, Some(LhtGateOutcome::NudgePlanRequired(_))));
assert_eq!(session.plan_gate_rounds, round);
}
assert!(evaluate_plan_bootstrap(&msgs, &mut session, "en").is_none());
assert_eq!(session.plan_gate_rounds, nudge::MAX_PLAN_GATE_ROUNDS);
}
#[test]
fn strict_completion_gate_raises_modes() {
use zagens_core::long_horizon::{
CompletionGateConfig, CompletionGateMode, GenericGateMode,
};
let mut base = CompletionGateConfig::default();
base.auto_verify_replay = GenericGateMode::Observe;
base.toolchain_gate = GenericGateMode::Observe;
let auto = strict_completion_gate(&base, LhtMode::Auto);
assert_eq!(auto.mode, base.mode);
let strict = strict_completion_gate(&base, LhtMode::Strict);
assert_eq!(strict.mode, CompletionGateMode::Enforce);
assert!(strict.stub_gate.is_enforce());
assert_eq!(strict.auto_verify_replay, GenericGateMode::Enforce);
assert_eq!(strict.toolchain_gate, GenericGateMode::Enforce);
}
}