Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion crates/jp_cli/src/cmd/conversation/edit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,8 @@ async fn generate_titles(
tool_choice: jp_config::assistant::tool_choice::ToolChoice::default(),
};

let retry_config = RetryConfig::default();
let retry_config =
RetryConfig::default().with_max_response_bytes(config.assistant.request.max_response_bytes);
let llm_events =
collect_with_retry(provider.as_ref(), &model_details, query, &retry_config).await?;

Expand Down
4 changes: 3 additions & 1 deletion crates/jp_cli/src/cmd/conversation/summarize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ pub async fn generate_summary(
stream,
instructions,
&user_message,
app_cfg.assistant.request.max_response_bytes,
)
.await
}
Expand All @@ -120,8 +121,9 @@ async fn summarize_stream(
mut stream: ConversationStream,
instructions: &str,
user_message: &str,
max_response_bytes: u32,
) -> Result<String> {
let retry_config = RetryConfig::default();
let retry_config = RetryConfig::default().with_max_response_bytes(max_response_bytes);

loop {
let thread = ThreadBuilder::default()
Expand Down
39 changes: 38 additions & 1 deletion crates/jp_cli/src/cmd/conversation/summarize_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use jp_llm::{
};

use super::{
StreamOutcome, collect_range_events, failure_reason, summarize_events, summarize_stream,
Error, StreamOutcome, collect_range_events, failure_reason, summarize_events, summarize_stream,
};

/// A stream that produced `text` and then stopped for `reason`.
Expand Down Expand Up @@ -53,6 +53,14 @@ fn test_model_id() -> ModelIdConfig {
async fn summarize_with(
batches: Vec<Vec<Event>>,
stream: ConversationStream,
) -> super::Result<String> {
summarize_with_ceiling(batches, stream, 1_048_576).await
}

async fn summarize_with_ceiling(
batches: Vec<Vec<Event>>,
stream: ConversationStream,
max_response_bytes: u32,
) -> super::Result<String> {
let provider = MockProvider::with_batches(batches);
let model_id = test_model_id();
Expand All @@ -65,10 +73,39 @@ async fn summarize_with(
stream,
"instructions",
"summarize",
max_response_bytes,
)
.await
}

/// A summary request honors the configured ceiling rather than a hardcoded
/// default, and does not re-request the response after breaching it.
#[tokio::test]
async fn summarize_applies_the_configured_output_ceiling() {
// One scripted batch is deliberate: `MockProvider` panics on a second
// request, so a ceiling misclassified as retryable fails loudly here.
// 30 bytes of content against a 25-byte ceiling.
let batches = vec![stream_with_text(
"012345678901234567890123456789",
FinishReason::Completed,
)];

let error = summarize_with_ceiling(batches, range_stream(&["sig"]), 25)
.await
.expect_err("the summary must stop at the configured ceiling");

// The default ceiling is 1 MiB; 30 bytes only breaches the configured 25,
// so reaching this arm proves the setting was threaded through.
assert!(
matches!(
error,
Error::Llm(jp_llm::Error::Stream(ref e))
if e.kind == jp_llm::StreamErrorKind::OutputLimit
),
"got: {error:?}"
);
}

fn build_stream_with_turns(count: usize) -> ConversationStream {
let mut stream = ConversationStream::new_test();
for i in 0..count {
Expand Down
1 change: 1 addition & 0 deletions crates/jp_cli/src/cmd/query/interrupt/signals_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ fn make_retry_state(max_retries: u32) -> StreamRetryState {
base_backoff_ms: 1,
max_backoff_secs: 1,
stream_idle_timeout_secs: 120,
max_response_bytes: 1_048_576,
cache: CachePolicy::default(),
};
StreamRetryState::new(config, false)
Expand Down
3 changes: 3 additions & 0 deletions crates/jp_cli/src/cmd/query/stream/retry_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ fn make_retry_state(max_retries: u32) -> StreamRetryState {
base_backoff_ms: 1, // 1ms for fast tests
max_backoff_secs: 1,
stream_idle_timeout_secs: 120,
max_response_bytes: 1_048_576,
cache: CachePolicy::default(),
};
StreamRetryState::new(config, false)
Expand Down Expand Up @@ -85,6 +86,7 @@ fn backoff_uses_retry_after_when_present() {
base_backoff_ms: 1,
max_backoff_secs: 120,
stream_idle_timeout_secs: 120,
max_response_bytes: 1_048_576,
cache: CachePolicy::default(),
};
let state = StreamRetryState::new(config, false);
Expand Down Expand Up @@ -341,6 +343,7 @@ async fn interrupt_during_backoff_cuts_wait_short() {
base_backoff_ms: 1,
max_backoff_secs: 120,
stream_idle_timeout_secs: 120,
max_response_bytes: 1_048_576,
cache: CachePolicy::default(),
};
let mut retry_state = StreamRetryState::new(config, false);
Expand Down
12 changes: 10 additions & 2 deletions crates/jp_cli/src/cmd/query/tool/inquiry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,10 @@ pub struct InquiryConfig {
pub model: ModelDetails,
pub system_prompt: Option<String>,
pub sections: Vec<SectionConfig>,

/// Output ceiling for the inquiry request, from
/// `assistant.request.max_response_bytes`.
pub max_response_bytes: u32,
}

/// Resolves inquiries by making structured output calls to an LLM provider.
Expand Down Expand Up @@ -188,7 +192,10 @@ impl LlmInquiryBackend {
}

/// Look up the effective config for this tool/question pair.
fn config_for(&self, tool_name: &str, question_id: &str) -> &InquiryConfig {
///
/// Returns the per-question override when one exists, otherwise the default
/// built from the global inquiry config merged with the parent assistant.
pub(crate) fn config_for(&self, tool_name: &str, question_id: &str) -> &InquiryConfig {
self.overrides
.get(&(tool_name.to_owned(), question_id.to_owned()))
.unwrap_or(&self.default_config)
Expand Down Expand Up @@ -282,7 +289,8 @@ impl InquiryBackend for LlmInquiryBackend {
tool_choice: ToolChoice::None,
};

let retry_config = RetryConfig::default();
let retry_config =
RetryConfig::default().with_max_response_bytes(config.max_response_bytes);
let llm_events = tokio::select! {
biased;
() = cancellation_token.cancelled() => {
Expand Down
3 changes: 3 additions & 0 deletions crates/jp_cli/src/cmd/query/tool/inquiry_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ fn test_inquiry_config(provider: MockProvider) -> InquiryConfig {
model: test_model(),
system_prompt: None,
sections: vec![],
max_response_bytes: 1_048_576,
}
}

Expand Down Expand Up @@ -323,6 +324,7 @@ async fn llm_backend_uses_per_question_override() {
model: test_model(),
system_prompt: Some("Override prompt.".into()),
sections: vec![],
max_response_bytes: 1_048_576,
};

let overrides = IndexMap::from([(("test_tool".into(), "confirm".into()), override_config)]);
Expand Down Expand Up @@ -557,6 +559,7 @@ async fn dedicated_model_backend_returns_answer() {
}),
system_prompt: Some("Answer concisely.".to_string()),
sections: vec![],
max_response_bytes: 1_048_576,
};

let backend = LlmInquiryBackend::new(config, IndexMap::new(), vec![], vec![]);
Expand Down
43 changes: 42 additions & 1 deletion crates/jp_cli/src/cmd/query/turn_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,11 @@ use jp_llm::{
error::StreamError,
event::{Event, EventPart, FinishReason, ToolCallPart},
model::ModelDetails,
output_limit_bytes,
provider::get_provider,
query::ChatQuery,
tool::{InvocationContext, ToolDefinition, executor::Executor},
with_idle_timeout,
with_idle_timeout, with_output_limit,
};
use jp_printer::{ErrChannel, Printer};
use jp_workspace::{ConversationLock, ConversationMut};
Expand Down Expand Up @@ -206,6 +207,7 @@ pub(super) async fn run_turn_loop(
0 => None,
secs => Some(Duration::from_secs(u64::from(secs))),
};
let output_limit = output_limit_bytes(cfg.assistant.request.max_response_bytes);
let mut turn_coordinator = TurnCoordinator::new(
printer.clone(),
cfg.style.clone(),
Expand Down Expand Up @@ -342,6 +344,14 @@ pub(super) async fn run_turn_loop(
Some(idle) => with_idle_timeout(raw_stream, idle),
None => raw_stream,
};
// Wrapped outside the provider stream, so the bytes of every
// chained continuation accumulate against a single ceiling
// rather than resetting per link. Bytes the provider discards
// while merging those links are billed but never seen here.
let raw_stream = match output_limit {
Some(max) => with_output_limit(raw_stream, max),
None => raw_stream,
};
let llm_stream = StreamSource::Llm(
raw_stream
.fuse()
Expand Down Expand Up @@ -873,6 +883,26 @@ async fn build_inquiry_backend(
.clone()
.or_else(|| cfg.assistant.system_prompt.clone());

// Same fallback for the output ceiling: an inquiry can be held to a tighter
// (or looser) ceiling than the parent assistant.
//
// `AssistantOverrideConfig::request` is a resolved `RequestConfig`, so a
// block where the user set only a sibling field (say `cache`) arrives here
// with every other field at Rust's `Default` rather than its schematic
// default. A `0` therefore cannot be distinguished from "unset", and reading
// it as the ceiling's disable sentinel would silently drop the runaway guard
// for every inquiry. Treat it as "inherit" instead, matching the block's
// documented unset-means-inherit rule. A per-question override carries real
// `Option`s, so `0` still disables the ceiling there.
let default_max_response_bytes = inquiry_override
.request
.as_ref()
.map_or(0, |request| request.max_response_bytes);
let default_max_response_bytes = match default_max_response_bytes {
0 => cfg.assistant.request.max_response_bytes,
bytes => bytes,
};

// Track providers we've already constructed to avoid duplicates.
let mut providers: IndexMap<ProviderId, Arc<dyn Provider>> = IndexMap::new();

Expand Down Expand Up @@ -920,6 +950,7 @@ async fn build_inquiry_backend(
model: inquiry_model,
system_prompt: default_system_prompt,
sections: sections.clone(),
max_response_bytes: default_max_response_bytes,
}
} else {
providers.insert(model.id.provider, Arc::clone(&provider));
Expand All @@ -929,6 +960,7 @@ async fn build_inquiry_backend(
model: model.clone(),
system_prompt: default_system_prompt,
sections: sections.clone(),
max_response_bytes: default_max_response_bytes,
}
};

Expand Down Expand Up @@ -1018,11 +1050,20 @@ async fn build_inquiry_overrides(
.map(|s| s.to_string())
.or_else(|| default_config.system_prompt.clone());

// Output ceiling follows the same order. `default_config` already
// carries the global-inquiry-or-parent value, so an unset
// per-question ceiling inherits it.
let max_response_bytes = per_q
.request
.max_response_bytes
.unwrap_or(default_config.max_response_bytes);

overrides.insert((tool_name.to_owned(), question_id.clone()), InquiryConfig {
provider: inq_provider,
model: inq_model,
system_prompt,
sections: default_config.sections.clone(),
max_response_bytes,
});
}
}
Expand Down
Loading
Loading