diff --git a/src/openhuman/agent/harness/engine/core.rs b/src/openhuman/agent/harness/engine/core.rs index b413348e4..4ad43b628 100644 --- a/src/openhuman/agent/harness/engine/core.rs +++ b/src/openhuman/agent/harness/engine/core.rs @@ -27,7 +27,7 @@ use crate::openhuman::agent::stop_hooks::{current_stop_hooks, StopDecision, Turn use crate::openhuman::context::guard::{ContextCheckResult, ContextGuard}; use crate::openhuman::context::{summarize_chat_history, EngineAutocompact}; use crate::openhuman::inference::provider::{ - ChatMessage, ChatRequest, Provider, ProviderCapabilityError, + ChatMessage, ChatRequest, Provider, ProviderCapabilityError, AGENT_TURN_MAX_OUTPUT_TOKENS, }; use super::super::parse::build_native_assistant_history; @@ -528,7 +528,10 @@ pub(crate) async fn run_turn_engine( messages: &prepared_messages_vec, tools: request_tools, stream: delta_tx_opt.as_ref(), - max_tokens: None, + // Cap the turn so reservation-pricing providers price their + // pre-flight against a realistic budget, not the full output + // window (TAURI-RUST-C62). + max_tokens: Some(AGENT_TURN_MAX_OUTPUT_TOKENS), }, model, temperature, diff --git a/src/openhuman/agent/harness/session/turn/session_io.rs b/src/openhuman/agent/harness/session/turn/session_io.rs index 6a98143dc..69078ae25 100644 --- a/src/openhuman/agent/harness/session/turn/session_io.rs +++ b/src/openhuman/agent/harness/session/turn/session_io.rs @@ -6,7 +6,9 @@ use super::super::types::Agent; use crate::openhuman::agent::harness; use crate::openhuman::agent::progress::AgentProgress; use crate::openhuman::context::ARCHIVIST_EXTRACTION_PROMPT; -use crate::openhuman::inference::provider::{ChatMessage, ChatRequest, ProviderDelta, UsageInfo}; +use crate::openhuman::inference::provider::{ + ChatMessage, ChatRequest, ProviderDelta, UsageInfo, AGENT_TURN_MAX_OUTPUT_TOKENS, +}; impl Agent { // ───────────────────────────────────────────────────────────────── @@ -120,7 +122,8 @@ impl Agent { messages: &messages, tools: None, stream: delta_tx_opt.as_ref(), - max_tokens: None, + // Reservation-pricing pre-flight budget cap (TAURI-RUST-C62). + max_tokens: Some(AGENT_TURN_MAX_OUTPUT_TOKENS), }, effective_model, self.temperature, diff --git a/src/openhuman/agent/harness/session/turn_engine_adapter.rs b/src/openhuman/agent/harness/session/turn_engine_adapter.rs index 4db311371..75fdb7f85 100644 --- a/src/openhuman/agent/harness/session/turn_engine_adapter.rs +++ b/src/openhuman/agent/harness/session/turn_engine_adapter.rs @@ -46,6 +46,7 @@ use crate::openhuman::agent_tool_policy::ToolPolicySession; use crate::openhuman::context::ReductionOutcome; use crate::openhuman::inference::provider::{ ChatMessage, ChatRequest, ConversationMessage, Provider, ProviderDelta, ToolCall, UsageInfo, + AGENT_TURN_MAX_OUTPUT_TOKENS, }; use crate::openhuman::tools::{Tool, ToolSpec}; @@ -476,7 +477,8 @@ impl CheckpointStrategy for AgentCheckpoint { messages: &messages, tools: None, stream: delta_tx_opt.as_ref(), - max_tokens: None, + // Reservation-pricing pre-flight budget cap (TAURI-RUST-C62). + max_tokens: Some(AGENT_TURN_MAX_OUTPUT_TOKENS), }, &self.model, self.temperature, diff --git a/src/openhuman/agent/harness/subagent_runner/ops/checkpoint.rs b/src/openhuman/agent/harness/subagent_runner/ops/checkpoint.rs index 8b9b4cf2f..936a4bd8f 100644 --- a/src/openhuman/agent/harness/subagent_runner/ops/checkpoint.rs +++ b/src/openhuman/agent/harness/subagent_runner/ops/checkpoint.rs @@ -5,7 +5,9 @@ //! instead of erroring. Falls back to a deterministic digest summary if the //! summarization call fails or returns no prose. -use crate::openhuman::inference::provider::{ChatMessage, ChatRequest, Provider}; +use crate::openhuman::inference::provider::{ + ChatMessage, ChatRequest, Provider, AGENT_TURN_MAX_OUTPUT_TOKENS, +}; /// Sub-agent [`CheckpointStrategy`]: when the iteration cap is hit, summarize /// the run-so-far into a resumable checkpoint (so the delegating agent can @@ -44,7 +46,9 @@ impl super::super::super::engine::CheckpointStrategy for SubagentCheckpoint<'_> messages: &summary_input, tools: None, stream: None, - max_tokens: None, + // Bounded progress-summary turn; cap also keeps the + // reservation-pricing pre-flight realistic (TAURI-RUST-C62). + max_tokens: Some(AGENT_TURN_MAX_OUTPUT_TOKENS), }, &self.model, self.temperature, diff --git a/src/openhuman/inference/provider/compatible_tests.rs b/src/openhuman/inference/provider/compatible_tests.rs index 753911c14..cf74b56e1 100644 --- a/src/openhuman/inference/provider/compatible_tests.rs +++ b/src/openhuman/inference/provider/compatible_tests.rs @@ -166,6 +166,39 @@ fn native_request_serializes_max_tokens_only_when_set() { ); } +#[test] +fn agent_turn_cap_reaches_the_wire() { + // The agent turns now set `max_tokens: Some(AGENT_TURN_MAX_OUTPUT_TOKENS)` + // (#4005); assert that exact cap serializes onto the wire so a careless edit + // to the const — or a regression to `None` on the agent path — fails CI and + // can't silently restore the full-window reservation that 402s low-balance + // BYO users (TAURI-RUST-C62). + let cap = crate::openhuman::inference::provider::AGENT_TURN_MAX_OUTPUT_TOKENS; + assert!( + (8192..=32768).contains(&cap), + "agent cap must stay well above realistic turns yet below the model's full output window; got {cap}" + ); + let req = super::NativeChatRequest { + model: "anthropic/claude-fable-5".to_string(), + messages: Vec::new(), + temperature: Some(0.0), + stream: Some(false), + tools: None, + tool_choice: None, + thread_id: None, + stream_options: None, + options: None, + frequency_penalty: None, + max_tokens: Some(cap), + }; + let json = serde_json::to_value(&req).unwrap(); + assert_eq!( + json.get("max_tokens").and_then(serde_json::Value::as_u64), + Some(u64::from(cap)), + "the agent-turn cap must be forwarded as OpenAI max_tokens" + ); +} + #[test] fn responses_request_serializes_max_output_tokens_only_when_set() { // The Responses-API branch must carry the cap as `max_output_tokens` so a diff --git a/src/openhuman/inference/provider/mod.rs b/src/openhuman/inference/provider/mod.rs index fe00ec935..c58ea525a 100644 --- a/src/openhuman/inference/provider/mod.rs +++ b/src/openhuman/inference/provider/mod.rs @@ -29,6 +29,7 @@ pub mod traits; pub use traits::{ ChatMessage, ChatRequest, ChatResponse, ConversationMessage, PromptCacheCapabilities, Provider, ProviderCapabilityError, ProviderDelta, ToolCall, ToolResultMessage, UsageInfo, + AGENT_TURN_MAX_OUTPUT_TOKENS, }; pub use billing_error::is_budget_exhausted_message; diff --git a/src/openhuman/inference/provider/traits.rs b/src/openhuman/inference/provider/traits.rs index 36e7471a4..1bbc61bef 100644 --- a/src/openhuman/inference/provider/traits.rs +++ b/src/openhuman/inference/provider/traits.rs @@ -142,6 +142,25 @@ pub enum ProviderDelta { ToolCallArgsDelta { call_id: String, delta: String }, } +/// Upper bound on output tokens requested for an agent chat turn. +/// +/// The agent loop used to leave `ChatRequest::max_tokens` `None` ("open-ended +/// generation"), but an unset cap makes reservation-pricing providers (e.g. +/// OpenRouter) reserve credit against the model's *entire* output window +/// (64k+) during their pre-flight balance check — so a modest-balance BYO user +/// can hit a `402` purely from the oversized reservation, a **preventable** +/// condition. Capping every agent turn at a realistic ceiling prices the +/// pre-flight against a budget the user can actually afford; a residual `402` +/// is then the genuine flat-balance case the insufficient-credits demote arm +/// is meant for (TAURI-RUST-C62; mirrors [`EXTRACTION_MAX_OUTPUT_TOKENS`] in +/// `memory_tree::score::extract::llm`). +/// +/// `16384` sits comfortably above any realistic single agent turn — `max_tokens` +/// is an upper bound, not a forced length, so the model still stops at its +/// natural end well below the cap on normal turns — while cutting the +/// reservation 4× versus a 64k window. +pub const AGENT_TURN_MAX_OUTPUT_TOKENS: u32 = 16384; + /// Request payload for provider chat calls. /// /// The system prompt is built once at session start and frozen for the @@ -161,16 +180,16 @@ pub struct ChatRequest<'a> { /// Optional upper bound on output tokens to request from the provider /// (`max_tokens` on the OpenAI-compatible wire). /// - /// Left `None` for open-ended generation (orchestrator, agent turns) - /// where the model should use its full budget. Set to a small concrete - /// value by callers whose output is bounded by construction — notably - /// memory extraction, whose response is a tiny structured-JSON object. + /// Left `None` only for the orchestrator's open-ended generation. Agent + /// turns cap at [`AGENT_TURN_MAX_OUTPUT_TOKENS`] and callers whose output + /// is bounded by construction set a small concrete value — notably memory + /// extraction, whose response is a tiny structured-JSON object. /// Beyond capping wasted generation, this stops credit-metered providers /// (e.g. OpenRouter) from reserving the model's *entire* output window /// during their pre-flight balance check: an unset `max_tokens` makes /// OpenRouter price the request against the full 64k+ window and 402 a /// low-balance BYO user who could easily afford the few thousand tokens - /// an extraction actually needs (TAURI-RUST-C62). + /// the turn actually needs (TAURI-RUST-C62). pub max_tokens: Option, } @@ -479,9 +498,11 @@ pub trait Provider: Send + Sync { /// OpenAI-compatible provider, which threads it onto the wire for /// credit-metered backends — TAURI-RUST-C62) override `chat()` directly. /// The drop is logged below rather than silently swallowed; it is not a - /// hard error because no production caller both sets `max_tokens` and - /// routes through a default-`chat()` provider (agent turns pass `None`; - /// memory extraction uses the compatible provider). + /// hard error because the production callers that set `max_tokens` (agent + /// turns at [`AGENT_TURN_MAX_OUTPUT_TOKENS`], memory extraction) route to + /// the compatible provider, which overrides `chat()` and honors the cap. + /// A provider on this default path simply forgoes the cap — harmless for + /// the non-reservation backends that don't override `chat()`. async fn chat( &self, request: ChatRequest<'_>,