Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ impl SessionDriver<OpenHumanRunContext> for OpenHumanSessionDriver {
accepted_history.clone(),
)
};
let close_copy = request.run_context.data.close_copy();
let close = tokio::select! {
biased;
_ = cancellation.cancelled() => return Err(stopped()),
Expand All @@ -239,6 +240,7 @@ impl SessionDriver<OpenHumanRunContext> for OpenHumanSessionDriver {
&request.history,
&user_message,
&outcome,
&close_copy,
close_by.map(|at| at.saturating_duration_since(Instant::now())),
supplemental_trace.as_ref(),
) => close,
Expand All @@ -252,12 +254,12 @@ impl SessionDriver<OpenHumanRunContext> for OpenHumanSessionDriver {
// fallback is not a completed answer; preserve resumability by making
// the durable reply an explicit checkpoint.
if outcome.text.trim().is_empty()
&& output.starts_with("I finished this turn without writing up a result.")
&& super::super::product_copy::is_final_summary(&close_copy, &output)
{
let results = super::super::product_copy::close_results(&outcome.tool_outcomes);
let rendered = super::super::product_copy::render_close_results(&results, 4_000);
output = tinyagents_harness::close::checkpoint_with_copy(
&super::super::product_copy::OPENHUMAN_CLOSE_COPY,
&close_copy,
self.max_iterations,
&rendered,
!results.is_empty(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ pub(super) struct GroundedClose {
/// Keeping this separate from the model-driven repair path guarantees zero
/// additional provider calls once its recovery budget is exhausted.
fn deterministic_final_summary_with_cause(
copy: &tinyagents_harness::close::CloseCopy,
results: &[tinyagents_harness::close::CloseToolResult],
stop_reason: Option<&str>,
failure_cause: Option<&tinyagents_harness::no_progress::ClassifiedStopCause>,
Expand All @@ -250,21 +251,21 @@ fn deterministic_final_summary_with_cause(
);
}
let rendered = product_copy::render_close_results(results, 4_000);
tinyagents_harness::close::final_summary_with_copy(
&product_copy::OPENHUMAN_CLOSE_COPY,
&rendered,
!results.is_empty(),
)
tinyagents_harness::close::final_summary_with_copy(copy, &rendered, !results.is_empty())
}

fn classified_halt_close(outcome: &TinyagentsTurnOutcome) -> Option<GroundedClose> {
fn classified_halt_close(
outcome: &TinyagentsTurnOutcome,
copy: &tinyagents_harness::close::CloseCopy,
) -> Option<GroundedClose> {
let reason = outcome.breaker_halt.as_deref().unwrap_or_default();
if outcome.failure_cause.is_none() && !reason.starts_with("Stopping after ") {
return None;
}
let records = product_copy::close_results(&outcome.tool_outcomes);
Some(GroundedClose {
output: deterministic_final_summary_with_cause(
copy,
&records,
Some(reason),
outcome.failure_cause.as_ref(),
Expand Down Expand Up @@ -365,13 +366,14 @@ pub(super) async fn repair_required_output(
/// output tokens while reasoning) rather than claiming the model finished.
fn close_instruction(
outcome: &TinyagentsTurnOutcome,
copy: &tinyagents_harness::close::CloseCopy,
needs_cap_close: bool,
rendered: &str,
) -> String {
if needs_cap_close {
return format!(
"{}\n\n<tool_records>\n{}\n</tool_records>",
wrap_harness_instruction(product_copy::OPENHUMAN_CLOSE_COPY.max_iteration_instruction),
wrap_harness_instruction(copy.max_iteration_instruction),
if rendered.is_empty() {
"(no tool calls completed)"
} else {
Expand All @@ -387,7 +389,7 @@ fn close_instruction(
);
}
tinyagents_harness::close::final_answer_instruction(
&product_copy::OPENHUMAN_CLOSE_COPY,
copy,
outcome.breaker_halt.as_deref(),
outcome.truncated,
rendered,
Expand All @@ -405,6 +407,7 @@ pub(super) async fn close_if_needed(
base_history: &[tinyinference_llm::message::Message],
user_message: &str,
outcome: &TinyagentsTurnOutcome,
copy: &tinyagents_harness::close::CloseCopy,
budget: Option<std::time::Duration>,
trace: Option<&Arc<SupplementalTrace>>,
) -> Option<GroundedClose> {
Expand All @@ -416,34 +419,32 @@ pub(super) async fn close_if_needed(
return None;
}

if let Some(close) = classified_halt_close(outcome) {
if let Some(close) = classified_halt_close(outcome, copy) {
return Some(close);
}
let records = product_copy::close_results(&outcome.tool_outcomes);
let rendered = product_copy::render_close_results(&records, 16_000);
let instruction = close_instruction(outcome, needs_cap_close, &rendered);
let instruction = close_instruction(outcome, copy, needs_cap_close, &rendered);
let history: Vec<tinyinference_llm::message::Message> = base_history
.iter()
.filter_map(message_to_native_chat_message)
.map(|message| crate::agent::tinyagents::chat_message_to_message(&message))
.collect();
let stop_reason = outcome.breaker_halt.as_deref();
let harness_needles = tinyagents_harness::close::instruction_needles(
&product_copy::OPENHUMAN_CLOSE_COPY,
stop_reason,
);
let harness_needles = product_copy::close_needles(copy, stop_reason);
let harness_phrases: Vec<&str> = harness_needles.iter().map(String::as_str).collect();
let fallback = || {
if needs_cap_close {
let checkpoint_results = product_copy::render_close_results(&records, 4_000);
tinyagents_harness::close::checkpoint_with_copy(
&product_copy::OPENHUMAN_CLOSE_COPY,
copy,
outcome.model_calls,
&checkpoint_results,
!records.is_empty(),
)
} else {
deterministic_final_summary_with_cause(
copy,
&records,
stop_reason,
outcome.failure_cause.as_ref(),
Expand Down Expand Up @@ -471,19 +472,15 @@ pub(super) async fn close_if_needed(
};
let verification_prompt = |candidate: &str| -> String {
tinyagents_harness::close::verification_prompt(
&product_copy::OPENHUMAN_CLOSE_COPY,
copy,
user_message,
&rendered,
candidate,
4_000,
)
};
let repair_instruction = |violation: tinyagents_harness::close::CloseViolation| -> String {
tinyagents_harness::close::repair_with_copy(
&product_copy::OPENHUMAN_CLOSE_COPY,
&instruction,
violation,
)
tinyagents_harness::close::repair_with_copy(copy, &instruction, violation)
};
let output = tinyagents_harness::close::grounded_close_with_options(
&model_client,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@ fn classified_halt_returns_partial_work_without_provider_usage() {
],
compaction: None,
};
let close = classified_halt_close(&outcome).expect("classified halt");
let close = classified_halt_close(&outcome, &product_copy::OPENHUMAN_CLOSE_COPY)
.expect("classified halt");
assert_eq!(close.usage.model_calls, 0);
assert!(close.output.contains("permission"), "{}", close.output);
assert!(
Expand Down Expand Up @@ -147,6 +148,7 @@ async fn grounded_close_uses_the_selected_model_and_keeps_verifier_out_of_contex
&history,
"Summarize the workspace.",
&outcome,
&product_copy::OPENHUMAN_CLOSE_COPY,
None,
None,
)
Expand Down Expand Up @@ -193,6 +195,7 @@ async fn terminal_failed_close_requests_are_counted_without_usage() {
&[],
"Summarize the workspace.",
&blank_outcome(false),
&product_copy::OPENHUMAN_CLOSE_COPY,
None,
None,
)
Expand Down Expand Up @@ -239,6 +242,7 @@ async fn close_state_machine_calls_emit_correlated_native_model_events() {
&[],
"Summarize the workspace.",
&blank_outcome(false),
&product_copy::OPENHUMAN_CLOSE_COPY,
None,
Some(&trace),
)
Expand Down Expand Up @@ -382,6 +386,7 @@ async fn close_stream_eof_and_timeout_emit_terminal_traces_without_timeout_usage
&[],
"Summarize the workspace.",
&blank_outcome(false),
&product_copy::OPENHUMAN_CLOSE_COPY,
pending.then_some(std::time::Duration::from_secs(1)),
Some(&trace),
)
Expand Down Expand Up @@ -507,7 +512,12 @@ fn blank_outcome(truncated: bool) -> TinyagentsTurnOutcome {
/// using tools" — which told the model its unfinished work was done.
#[test]
fn a_truncated_turn_is_closed_with_the_output_budget_named() {
let truncated = close_instruction(&blank_outcome(true), false, RECORDS);
let truncated = close_instruction(
&blank_outcome(true),
&product_copy::OPENHUMAN_CLOSE_COPY,
false,
RECORDS,
);
assert!(
truncated.contains("ran out of output tokens"),
"{truncated}"
Expand All @@ -518,7 +528,12 @@ fn a_truncated_turn_is_closed_with_the_output_budget_named() {
);
assert!(truncated.contains("<tool_records>"), "{truncated}");

let finished = close_instruction(&blank_outcome(false), false, RECORDS);
let finished = close_instruction(
&blank_outcome(false),
&product_copy::OPENHUMAN_CLOSE_COPY,
false,
RECORDS,
);
assert!(
finished.contains("You have finished using tools"),
"{finished}"
Expand All @@ -528,7 +543,12 @@ fn a_truncated_turn_is_closed_with_the_output_budget_named() {
/// The cap checkpoint still wins when the run also hit its call cap.
#[test]
fn a_capped_truncated_turn_gets_the_cap_checkpoint() {
let out = close_instruction(&blank_outcome(true), true, RECORDS);
let out = close_instruction(
&blank_outcome(true),
&product_copy::OPENHUMAN_CLOSE_COPY,
true,
RECORDS,
);
assert!(!out.contains("ran out of output tokens"), "{out}");
assert!(out.contains("<tool_records>"), "{out}");
}
Expand Down Expand Up @@ -680,3 +700,58 @@ fn supplemental_free_route_uses_zero_catalog_rates() {

#[path = "grounded_close_typed_stop_tests.rs"]
mod typed_stop;

const REWORDED_CAP_INSTRUCTION: &str = "Tell the user plainly, in their own language, what got \
done before you ran out of steps.";

/// A copy that rewords the cap instruction still gets the echo guard: a close
/// that repeats the reworded text back is repaired once, with the copy's own
/// repair wording, and the user receives the repaired message.
#[tokio::test]
async fn a_close_echoing_a_reworded_instruction_is_repaired_with_the_copys_wording() {
use tinyagents_harness::testkit::ScriptedModel;
use tinyinference_llm::model::ModelResponse;

let copy = tinyagents_harness::close::CloseCopy {
max_iteration_instruction: REWORDED_CAP_INSTRUCTION,
repair_template: "{repair_reason}|{instruction}",
repair_quoted_instruction: "REPAIR QUOTED",
..product_copy::OPENHUMAN_CLOSE_COPY
};
let echo = format!("Of course. {REWORDED_CAP_INSTRUCTION} Here is what I did.");
let scripted = Arc::new(ScriptedModel::new(vec![
ModelResponse::assistant(echo),
ModelResponse::assistant(CLEAN_REPLY),
ModelResponse::assistant("ACCEPT"),
]));
let source = TurnModelSource::from_model(scripted.clone());
let mut outcome = blank_outcome(false);
outcome.hit_cap = true;

let closed = close_if_needed(
&source,
"host-selected-model",
0.0,
None,
&tinytools_agent::dialect::NativeDialect,
&[],
"Summarize the workspace.",
&outcome,
&copy,
None,
None,
)
.await
.expect("a capped turn needs a close");

assert_eq!(closed.output, CLEAN_REPLY);
let requests = scripted.requests();
assert!(
format!("{:?}", requests[0].messages).contains(REWORDED_CAP_INSTRUCTION),
"the first close asks with the copy's own instruction"
);
assert!(
format!("{:?}", requests[1].messages).contains("REPAIR QUOTED"),
"the repair uses the copy's wording"
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ fn classified_halt_summary_uses_typed_cause_when_halt_copy_conflicts() {
compaction: None,
};

let close = classified_halt_close(&outcome).expect("classified halt");
let close = classified_halt_close(&outcome, &product_copy::OPENHUMAN_CLOSE_COPY)
.expect("classified halt");
assert!(
close.output.contains("inference credits"),
"typed out_of_credits cause must win over contradictory prose: {}",
Expand All @@ -62,7 +63,7 @@ fn classified_halt_summary_uses_typed_class_for_translated_notes() {

for (class, expected_copy) in cases {
let outcome = classified_halt(note, class);
let close = classified_halt_close(&outcome)
let close = classified_halt_close(&outcome, &product_copy::OPENHUMAN_CLOSE_COPY)
.unwrap_or_else(|| panic!("typed `{class}` cause must close the turn"));
assert!(
close.output.contains(expected_copy),
Expand All @@ -84,7 +85,8 @@ fn unrecognized_typed_class_does_not_fall_back_to_english_halt_phrases() {
"Stopping: the action is blocked by the security policy.",
"future_failure_class",
);
let close = classified_halt_close(&outcome).expect("typed halt");
let close =
classified_halt_close(&outcome, &product_copy::OPENHUMAN_CLOSE_COPY).expect("typed halt");

assert!(
close
Expand Down Expand Up @@ -140,7 +142,7 @@ fn classified_halt_maps_all_typed_stop_categories_and_aliases() {
"Interrompo: bloqueado por segurança; o texto foi traduzido.",
class,
);
let close = classified_halt_close(&outcome)
let close = classified_halt_close(&outcome, &product_copy::OPENHUMAN_CLOSE_COPY)
.unwrap_or_else(|| panic!("typed `{class}` cause must close the turn"));
assert!(
close.output.contains(expected_copy),
Expand Down Expand Up @@ -184,6 +186,7 @@ async fn typed_cause_without_halt_note_still_closes_and_keeps_operation() {
&[],
"search",
&outcome,
&product_copy::OPENHUMAN_CLOSE_COPY,
None,
None,
)
Expand Down
39 changes: 39 additions & 0 deletions crates/openhuman-core/src/agent/session_host/product_copy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,45 @@ pub(crate) const OPENHUMAN_STOP_COPY: tinyagents_harness::turn_stop::StopCopy =
pub(crate) const OPENHUMAN_CLOSE_COPY: tinyagents_harness::close::CloseCopy =
tinyagents_harness::close::ENGLISH_CLOSE_COPY;

/// The phrases that mark `copy`'s directions as echoed back to the user.
///
/// The harness derives them from `copy.instruction_needles`, a static list an
/// embedder's copy usually inherits from the English default. The instructions
/// the turn is actually given are added here, so a copy that rewrites one is
/// still recognised when the model repeats it.
pub(crate) fn close_needles(
copy: &tinyagents_harness::close::CloseCopy,
stop_reason: Option<&str>,
) -> Vec<String> {
let mut sources: Vec<&str> = copy.instruction_needles.to_vec();
for instruction in [
copy.max_iteration_instruction,
copy.final_write_instruction,
copy.final_answer_instruction,
copy.truncated_answer_instruction,
copy.stop_note_preamble,
] {
if !sources.contains(&instruction) {
sources.push(instruction);
}
}
if let Some(reason) = stop_reason {
sources.push(reason);
}
tinyagents_harness::close::harness_instruction_needles(&sources, copy.minimum_quoted_words)
}

/// Whether `output` is `copy`'s deterministic "finished without a result"
/// summary, judged by the wording its template opens with.
pub(crate) fn is_final_summary(copy: &tinyagents_harness::close::CloseCopy, output: &str) -> bool {
let opening = copy
.final_summary_template
.split('{')
.next()
.unwrap_or_default();
!opening.trim().is_empty() && output.starts_with(opening)
}

/// Capture bounded result text for the OpenHuman checkpoint and close paths.
pub(crate) fn close_results(
outcomes: &[tinyagents_harness::turn_outcome::ToolCallOutcome],
Expand Down
Loading
Loading