mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
[codex] rollout budget implementation (varlength 2/N) (#28494)
## Stack Depends on #28746. This PR implements shared rollout-budget accounting and model-visible reminders using the configuration defined in #28746. # Description / Main changes to Core: `AgentControl` will now be the area where "rollout level" features & accounting will have to live. It is incorrectly named for this responsibility, but I think it can hold all the necessary shared state & features (rollout token budget, mutliple thread interruption responsibilitym etc) In this PR, we have one "token ledger" that each thread will subtract from when sampling. The "charge" will occur when response.completed() is done and the calculation will be done on the responses api usage carrier. The calculation will weigh sampling and pre-fill tokens as specified. Every time the budget crosses the configured reminder threshold, a developer message is appended before the thread's next request This remaining budget will _always_ be restated/reminded after a compaction event. Expiration and fan-out interruption will be in the stacked follow-up (and also live in Agent Control). ## Reminders "You have weighted {session_tokens_left} tokens left in the shared session token budget." The first request in each thread context receives the current remainder. Later reminders are emitted after aggregate weighted usage crosses a configured interval. If several intervals are crossed before a thread sends another request, Core inserts one reminder with the latest remainder. Compaction response usage is charged before the next context starts. The next reminder is appended after the compaction summary, leaving the initial context content stable. ## Tests Integration coverage verifies: - weighted output and non-cached input accounting - initial and periodic reminders - shared accounting between a root and sub-agent - post-compaction remainder and message placement Local checks: - `just fmt` - `just test -p codex-core rollout_budget` - `git diff --check` The full workspace test suite was not run locally.
This commit is contained in:
committed by
GitHub
Unverified
parent
df5f122854
commit
32a696dbac
@@ -100,6 +100,7 @@ mod resume;
|
||||
mod resume_warning;
|
||||
mod review;
|
||||
mod rmcp_client;
|
||||
mod rollout_budget;
|
||||
mod rollout_list_find;
|
||||
mod safety_check_downgrade;
|
||||
mod search_tool;
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
use anyhow::Result;
|
||||
use codex_core::config::RolloutBudgetConfig;
|
||||
use codex_features::Feature;
|
||||
use codex_model_provider_info::built_in_model_providers;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::Op;
|
||||
use core_test_support::responses::ResponsesRequest;
|
||||
use core_test_support::responses::ev_assistant_message;
|
||||
use core_test_support::responses::ev_completed;
|
||||
use core_test_support::responses::ev_completed_with_tokens;
|
||||
use core_test_support::responses::ev_function_call;
|
||||
use core_test_support::responses::ev_response_created;
|
||||
use core_test_support::responses::mount_sse_once_match;
|
||||
use core_test_support::responses::mount_sse_sequence;
|
||||
use core_test_support::responses::sse;
|
||||
use core_test_support::responses::start_mock_server;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_event;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use std::time::Duration;
|
||||
use tokio::time::timeout;
|
||||
|
||||
const ROLLOUT_BUDGET: RolloutBudgetConfig = RolloutBudgetConfig {
|
||||
limit_tokens: 100,
|
||||
reminder_interval_tokens: 25,
|
||||
sampling_token_weight: 1.0,
|
||||
prefill_token_weight: 1.0,
|
||||
};
|
||||
|
||||
fn rollout_budget_texts(request: &ResponsesRequest) -> Vec<String> {
|
||||
request
|
||||
.message_input_texts("developer")
|
||||
.into_iter()
|
||||
.filter(|text| text.starts_with("<rollout_budget>"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn rollout_budget_message(remaining_tokens: i64) -> String {
|
||||
format!(
|
||||
"<rollout_budget>\nYou have {remaining_tokens} weighted tokens left in the shared session token budget.\n</rollout_budget>"
|
||||
)
|
||||
}
|
||||
|
||||
fn wire_request_contains(request: &wiremock::Request, text: &str) -> bool {
|
||||
std::str::from_utf8(&request.body).is_ok_and(|body| body.contains(text))
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn adds_weighted_initial_and_periodic_reminders() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let responses = mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
json!({
|
||||
"type": "response.completed",
|
||||
"response": {
|
||||
"id": "resp-1",
|
||||
"usage": {
|
||||
"input_tokens": 60,
|
||||
"input_tokens_details": { "cached_tokens": 40 },
|
||||
"output_tokens": 15,
|
||||
"output_tokens_details": null,
|
||||
"total_tokens": 75
|
||||
}
|
||||
}
|
||||
}),
|
||||
]),
|
||||
sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let test = test_codex()
|
||||
.with_config(|config| {
|
||||
config.rollout_budget = Some(RolloutBudgetConfig {
|
||||
sampling_token_weight: 2.0,
|
||||
prefill_token_weight: 0.5,
|
||||
..ROLLOUT_BUDGET
|
||||
});
|
||||
})
|
||||
.build(&server)
|
||||
.await?;
|
||||
|
||||
test.submit_turn("first turn").await?;
|
||||
test.submit_turn("second turn").await?;
|
||||
|
||||
let requests = responses.requests();
|
||||
assert_eq!(
|
||||
rollout_budget_texts(&requests[0]),
|
||||
vec![rollout_budget_message(/*remaining_tokens*/ 100)]
|
||||
);
|
||||
assert_eq!(
|
||||
rollout_budget_texts(&requests[1]),
|
||||
vec![
|
||||
rollout_budget_message(/*remaining_tokens*/ 100),
|
||||
rollout_budget_message(/*remaining_tokens*/ 60),
|
||||
]
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn subagent_usage_draws_from_the_shared_budget() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
const ROOT_PROMPT: &str = "spawn a budget worker";
|
||||
const CHILD_PROMPT: &str = "consume child budget";
|
||||
const FOLLOW_UP_PROMPT: &str = "report the shared budget";
|
||||
const SPAWN_CALL_ID: &str = "spawn-budget-worker";
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let spawn_args = json!({
|
||||
"message": CHILD_PROMPT,
|
||||
"task_name": "budget_worker",
|
||||
})
|
||||
.to_string();
|
||||
mount_sse_once_match(
|
||||
&server,
|
||||
|request: &wiremock::Request| wire_request_contains(request, ROOT_PROMPT),
|
||||
sse(vec![
|
||||
ev_response_created("root-1"),
|
||||
ev_function_call(SPAWN_CALL_ID, "spawn_agent", &spawn_args),
|
||||
ev_completed_with_tokens("root-1", /*total_tokens*/ 10),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
mount_sse_once_match(
|
||||
&server,
|
||||
|request: &wiremock::Request| wire_request_contains(request, "\"type\":\"agent_message\""),
|
||||
sse(vec![
|
||||
ev_response_created("child-1"),
|
||||
ev_completed_with_tokens("child-1", /*total_tokens*/ 30),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
mount_sse_once_match(
|
||||
&server,
|
||||
|request: &wiremock::Request| {
|
||||
wire_request_contains(request, SPAWN_CALL_ID)
|
||||
&& !wire_request_contains(request, "\"type\":\"agent_message\"")
|
||||
},
|
||||
sse(vec![
|
||||
ev_response_created("root-2"),
|
||||
ev_completed_with_tokens("root-2", /*total_tokens*/ 10),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let follow_up = mount_sse_once_match(
|
||||
&server,
|
||||
|request: &wiremock::Request| wire_request_contains(request, FOLLOW_UP_PROMPT),
|
||||
sse(vec![ev_response_created("root-3"), ev_completed("root-3")]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let test = test_codex()
|
||||
.with_config(|config| {
|
||||
config
|
||||
.features
|
||||
.enable(Feature::Collab)
|
||||
.expect("test config should allow multi-agent tools");
|
||||
config
|
||||
.features
|
||||
.enable(Feature::MultiAgentV2)
|
||||
.expect("test config should allow multi-agent v2");
|
||||
config.rollout_budget = Some(ROLLOUT_BUDGET);
|
||||
})
|
||||
.build(&server)
|
||||
.await?;
|
||||
|
||||
let mut created_threads = test.thread_manager.subscribe_thread_created();
|
||||
test.submit_turn(ROOT_PROMPT).await?;
|
||||
let child_thread_id = timeout(Duration::from_secs(10), created_threads.recv()).await??;
|
||||
let child_thread = test.thread_manager.get_thread(child_thread_id).await?;
|
||||
wait_for_event(child_thread.as_ref(), |event| {
|
||||
matches!(event, EventMsg::TurnComplete(_))
|
||||
})
|
||||
.await;
|
||||
test.submit_turn(FOLLOW_UP_PROMPT).await?;
|
||||
|
||||
let request = follow_up.single_request();
|
||||
assert_eq!(
|
||||
rollout_budget_texts(&request).last(),
|
||||
Some(&rollout_budget_message(/*remaining_tokens*/ 50))
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn restates_the_current_remainder_after_compaction() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let responses = mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_completed_with_tokens("resp-1", /*total_tokens*/ 20),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-compact"),
|
||||
ev_assistant_message("msg-compact", "compact summary"),
|
||||
ev_completed_with_tokens("resp-compact", /*total_tokens*/ 10),
|
||||
]),
|
||||
sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let mut model_provider = built_in_model_providers(/*openai_base_url*/ None)["openai"].clone();
|
||||
model_provider.name = "OpenAI-compatible test provider".to_string();
|
||||
model_provider.base_url = Some(format!("{}/v1", server.uri()));
|
||||
model_provider.supports_websockets = false;
|
||||
let test = test_codex()
|
||||
.with_config(move |config| {
|
||||
config.model_provider = model_provider;
|
||||
config.rollout_budget = Some(RolloutBudgetConfig {
|
||||
reminder_interval_tokens: 50,
|
||||
..ROLLOUT_BUDGET
|
||||
});
|
||||
})
|
||||
.build(&server)
|
||||
.await?;
|
||||
|
||||
test.submit_turn("first turn").await?;
|
||||
test.codex.submit(Op::Compact).await?;
|
||||
wait_for_event(&test.codex, |event| {
|
||||
matches!(event, EventMsg::TurnComplete(_))
|
||||
})
|
||||
.await;
|
||||
test.submit_turn("second turn").await?;
|
||||
|
||||
let requests = responses.requests();
|
||||
assert_eq!(
|
||||
rollout_budget_texts(&requests[2]),
|
||||
vec![rollout_budget_message(/*remaining_tokens*/ 70)],
|
||||
"a new context window should restate the current remainder"
|
||||
);
|
||||
let request_body = requests[2].body_json().to_string();
|
||||
let summary_position = request_body
|
||||
.find("compact summary")
|
||||
.expect("post-compaction request should contain the summary");
|
||||
let reminder_position = request_body
|
||||
.find("You have 70 weighted tokens left in the shared session token budget.")
|
||||
.expect("post-compaction request should contain the current remainder");
|
||||
assert!(
|
||||
summary_position < reminder_position,
|
||||
"the current remainder should follow the compaction summary"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn restates_the_current_remainder_after_rollback() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let responses = mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_completed_with_tokens("resp-1", /*total_tokens*/ 30),
|
||||
]),
|
||||
sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let test = test_codex()
|
||||
.with_config(|config| {
|
||||
config.rollout_budget = Some(RolloutBudgetConfig {
|
||||
reminder_interval_tokens: 50,
|
||||
..ROLLOUT_BUDGET
|
||||
});
|
||||
})
|
||||
.build(&server)
|
||||
.await?;
|
||||
|
||||
test.submit_turn("rolled-back turn").await?;
|
||||
test.codex
|
||||
.submit(Op::ThreadRollback { num_turns: 1 })
|
||||
.await?;
|
||||
wait_for_event(&test.codex, |event| {
|
||||
matches!(event, EventMsg::ThreadRolledBack(_))
|
||||
})
|
||||
.await;
|
||||
test.submit_turn("turn after rollback").await?;
|
||||
|
||||
let requests = responses.requests();
|
||||
assert_eq!(
|
||||
rollout_budget_texts(&requests[1]),
|
||||
vec![rollout_budget_message(/*remaining_tokens*/ 70)],
|
||||
"rollback should rearm the current budget reminder without refunding usage"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user