blob: 8ca3cec62e6fdbdab6ae127f00d1078af1757060 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
use maka_agent_facts::language::Language;
use crate::{
Inner, RunContext, RunError, RunInput, history,
runner::{append, digest},
};
use maka_agent_facts::context::ModelPurpose;
use maka_agent_facts::event::Fact;
use maka_agent_facts::event::ModelInterruption;
use maka_agent_facts::model::ModelStep;
use maka_event_log::context::ModelContextSource;
use maka_model::prompt::Message;
use maka_model::{ModelError, ModelEvent, ModelRequest, StepBuilder, ToolDefinition};
use std::collections::HashSet;
use std::sync::Arc;
use tokio_util::sync::CancellationToken;
use uuid::Uuid;
pub(super) struct Main<'a> {
pub lane: maka_model::Conversation,
pub continuation_base: Option<u64>,
pub surface: Arc<crate::request_composition::Surface>,
pub tools: &'a maka_tools::RequestTools<'a>,
pub prior_unknown: bool,
}
pub(super) enum Attempt<'a> {
Main(Main<'a>),
Summary {
surface: Arc<crate::request_composition::Surface>,
},
}
#[expect(
clippy::large_enum_variant,
reason = "One result per request; keep the ordinary response inline without another allocation."
)]
pub(super) enum Outcome {
Response(ModelStep),
FinishedByTool,
}
impl From<(String, ModelStep)> for Outcome {
fn from((_, output): (String, ModelStep)) -> Self {
Self::Response(output)
}
}
#[allow(clippy::too_many_arguments)] // Keep the admitted history policy explicit at each model request.
pub(super) async fn prompt<W>(
inner: &Inner,
input: &RunInput<W>,
source: &ModelContextSource,
cancellation: &CancellationToken,
continuation_base: Option<u64>,
current: &str,
prior_unknown: bool,
) -> Result<Vec<Message>, RunError> {
let route = route_identity(input)?;
let replay = continuation_base.map(|base| history::Replay {
base,
current,
route: &route,
model: &input.provider.model,
});
let mut prompt = history::materialize_replay(
&inner.log,
&source.tail,
source.anchor.as_ref(),
&input.invocation.session_id,
input.supports_vision,
cancellation,
replay,
prior_unknown,
input.configuration.language,
)
.await?;
if let Some(baseline) = &source.baseline {
let label = match input.configuration.language {
Language::En => {
"Earlier-history checkpoint. Following history may repeat the active turn's opening; reconcile later corrections and results with this snapshot before continuing:\n"
}
Language::ZhCn => {
"较早历史的检查点。后续历史可能重现当前轮次的开头消息;继续前请结合后来的纠正和结果更新此快照中的状态:\n"
}
Language::ZhTw => {
"較早歷史的檢查點。後續歷史可能重現目前回合的開頭訊息;繼續前請結合後來的修正和結果更新此快照中的狀態:\n"
}
};
let text = format!("{label}\n{}", baseline.checkpoint.summary.text);
prompt.insert(0, Message::user(text));
}
let system_text = crate::request_composition::render_system(
input
.configuration
.system_prompt
.as_ref()
.map(|system| system.text.as_str()),
&crate::request_composition::session_context(input),
);
if let Some(system_text) = system_text {
prompt.insert(
0,
Message::System {
content: system_text,
provider_options: None,
},
);
}
Ok(prompt)
}
pub(super) fn prior_unknown_notice(source: &ModelContextSource, language: Language) -> String {
let events =
source
.anchor
.iter()
.map(|event| &event.event)
.chain(source.tail.iter().filter_map(|event| match event {
maka_event_log::context::ContextEvent::Canonical(event) => Some(&event.event),
maka_event_log::context::ContextEvent::Archived(_)
| maka_event_log::context::ContextEvent::ModelItems(_) => None,
}));
let events: Vec<_> = events.collect();
let sealed: HashSet<_> = events
.iter()
.filter_map(|event| match &event.fact {
Fact::InvocationEnded {
outcome: maka_agent_facts::event::InvocationOutcome::Failed { class, .. },
} if class == "outcome_unknown" => Some(event.invocation.invocation_id.as_str()),
_ => None,
})
.collect();
let settled: HashSet<_> = events
.iter()
.filter_map(|event| match &event.fact {
Fact::ToolSettled { operation_id, .. } => Some((
event.invocation.invocation_id.as_str(),
operation_id.as_str(),
)),
_ => None,
})
.collect();
let unknown: Vec<_> = events
.iter()
.filter_map(|event| match &event.fact {
Fact::ToolDispatched {
operation_id, name, ..
} if sealed.contains(event.invocation.invocation_id.as_str())
&& !settled.contains(&(
event.invocation.invocation_id.as_str(),
operation_id.as_str(),
)) =>
{
Some((name.as_str(), operation_id.as_str()))
}
_ => None,
})
.collect();
if unknown.is_empty() {
return String::new();
}
let mut notice = String::from(match language {
Language::En => {
"Prior execution was interrupted after these tools were dispatched. Their results were never recorded; effects may or may not have happened. Inspect current state before repeating any action. This is historical uncertainty, not a new tool result:"
}
Language::ZhCn => {
"先前执行在派发以下工具后中断,未记录结果;操作可能已经生效。重复操作前先检查当前状态。这是历史不确定性,不是新的工具结果:"
}
Language::ZhTw => {
"先前執行在派發以下工具後中斷,未記錄結果;操作可能已經生效。重複操作前先檢查目前狀態。這是歷史不確定性,不是新的工具結果:"
}
});
for (name, operation) in unknown.iter().take(64) {
notice.push_str(&match language {
Language::En => format!("\n- {name} (operation {operation})"),
Language::ZhCn => format!("\n- {name}(操作 {operation})"),
Language::ZhTw => format!("\n- {name}(操作 {operation})"),
});
}
if unknown.len() > 64 {
let remaining = unknown.len() - 64;
notice.push_str(&match language {
Language::En => format!("\n- and {remaining} more unknown operations"),
Language::ZhCn => format!("\n- 另有 {remaining} 个结果未知的操作"),
Language::ZhTw => format!("\n- 另有 {remaining} 個結果未知的操作"),
});
}
notice
}
pub(super) struct RequestSource {
pub evidence: maka_event_log::context::SourceEvidence,
pub effective_digest: String,
pub checkpoint: Option<String>,
}
impl From<&ModelContextSource> for RequestSource {
fn from(source: &ModelContextSource) -> Self {
Self {
evidence: source.source_evidence.clone(),
effective_digest: source.effective_source_digest.clone(),
checkpoint: source
.baseline
.as_ref()
.map(|baseline| baseline.event_id.clone()),
}
}
}
pub(super) async fn read_source(
inner: &Inner,
input: &RunContext,
prior_unknown: bool,
cancellation: &CancellationToken,
) -> Result<ModelContextSource, RunError> {
// Only abandon the read reply. Its snapshot remains owned by the log's
// independent reader until completion; no append or external effect is raced.
tokio::select! {
biased;
_ = cancellation.cancelled() => Err(RunError::Cancelled),
source = async {
if prior_unknown {
inner.log.read_manual_message_context(
&input.invocation.session_id,
&input.invocation.invocation_id,
).await
} else {
inner.log.read_model_context(
&input.invocation.session_id,
Some(&input.invocation.invocation_id),
).await
}
} => Ok(source?),
}
}
pub(super) async fn execute_main(
inner: &Arc<Inner>,
input: &RunContext,
context: ModelContextSource,
prompt: Vec<Message>,
definitions: Vec<ToolDefinition>,
main: Main<'_>,
cancellation: &CancellationToken,
) -> (u64, Result<Outcome, RunError>) {
let Main {
lane,
continuation_base,
surface,
tools,
prior_unknown,
} = main;
let mut source = RequestSource::from(&context);
let mut high_water = source.evidence.high_water;
drop(context);
let mut retry = Some(prompt);
let result = async {
let mut failures = 0;
loop {
// Preserve captured capabilities. Rebuild history only when a completed
// item advanced canonical progress, never from a shortened WS request.
let mut progressed = false;
let result = execute_once(
inner,
input,
&source,
retry
.as_ref()
.expect("unprogressed attempt retains its prompt")
.clone(),
definitions.clone(),
Attempt::Main(Main {
lane: lane.clone(),
continuation_base,
surface: surface.clone(),
tools,
prior_unknown,
}),
cancellation,
&mut progressed,
&mut retry,
)
.await;
let delay = match &result {
Err(RunError::Model(maka_model::ModelError::Provider(failure)))
if failure.replay_safe() || (progressed && failure.retained_output_safe()) =>
{
if cancellation.is_cancelled() {
return Err(RunError::Cancelled);
}
// A successful finishing effect is already authoritative. Safe
// stream recovery must not reopen this turn for another model.
if tools.finished() {
return Ok(Outcome::FinishedByTool);
}
if failures >= 9 {
if !lane.try_switch_fallback_transport() {
return result.map(Outcome::from);
}
failures = 0;
failure.retry_after().unwrap_or_default()
} else {
// Codex's ordinary stream recovery starts at 200 ms with
// +/-10% jitter. The bounded attempt count caps this delay.
let base_ms = 200u64 << failures;
failures += 1;
failure.retry_after().unwrap_or_else(|| {
std::time::Duration::from_millis(fastrand::u64(
base_ms * 9 / 10..=base_ms * 11 / 10,
))
})
}
}
_ => return result.map(Outcome::from),
};
if progressed {
// Accepted progress replaces the frozen retry prompt. Release it
// before materializing the next canonical source.
drop(retry.take());
let context = read_source(inner, input, prior_unknown, cancellation).await?;
high_water = context.source_evidence.high_water;
source = RequestSource::from(&context);
let prompt = surface.apply(
self::prompt(
inner,
input,
&context,
cancellation,
continuation_base,
&input.invocation.invocation_id,
prior_unknown,
)
.await?,
);
drop(context);
retry = Some(prompt);
}
// execute_once has drained the worker and committed ModelInterrupted.
// A local/storage error cannot reach this wait or authorize another send.
tokio::select! {
biased;
_ = cancellation.cancelled() => return Err(RunError::Cancelled),
_ = tokio::time::sleep(delay) => {}
}
}
}
.await;
(high_water, result)
}
#[allow(clippy::too_many_arguments)]
pub(super) async fn execute_once(
inner: &Arc<Inner>,
input: &RunContext,
source: &RequestSource,
prompt: Vec<Message>,
definitions: Vec<ToolDefinition>,
attempt: Attempt<'_>,
cancellation: &CancellationToken,
progressed: &mut bool,
retry: &mut Option<Vec<Message>>,
) -> Result<(String, ModelStep), RunError> {
let (purpose, lane, surface, tools) = match attempt {
Attempt::Main(Main {
lane,
surface,
tools,
..
}) => (ModelPurpose::Main, Some(lane), surface, Some(tools)),
Attempt::Summary { surface } => (ModelPurpose::Summary, None, surface, None),
};
if cancellation.is_cancelled() {
return Err(RunError::Cancelled);
}
let prepared = prepare_request(
input,
prompt,
definitions,
surface.max_output_tokens,
purpose,
)?;
let binding = surface.adapter.clone();
let composition = surface.evidence.clone();
let item_acceptance = purpose == ModelPurpose::Main && binding.supports_item_acceptance();
let step_id = Uuid::new_v4().to_string();
let event = maka_agent_facts::event::RuntimeEvent::new(
input.invocation.clone(),
Fact::ModelRequested {
item_acceptance,
effective_source_digest: Some(source.effective_digest.clone()),
purpose,
context: input.context.clone(),
context_breakdown: Some(prepared.context_breakdown),
step_id: step_id.clone(),
model_id: input.provider.model.clone(),
source_scope: source.evidence.scope.clone(),
source_high_water: source.evidence.high_water,
source_digest: source.evidence.digest.clone(),
input_digest: prepared.input_digest,
route_identity: prepared.route_identity,
checkpoint_event_id: source.checkpoint.clone(),
},
);
let mut write =
maka_agent_facts::event::EventWrite::plain(event)?.with_composition(composition)?;
if let Some(pricing) = &inner.pricing {
write = write.with_quote(
pricing
.quote(&input.provider_id, &input.provider.model)
.await?,
)?;
}
use maka_agent_facts::event::EventSink;
inner.log.clone().commit(write).await?;
let result: Result<_, RunError> = async {
let stream = inner
.model
.stream_with_adapter(prepared.request, cancellation.clone(), lane, binding)
.await?;
receive(
inner,
input,
&step_id,
purpose,
stream,
tools
.filter(|_| item_acceptance)
.map(|tools| tools.clone().into_step(&step_id)),
progressed,
cancellation,
retry,
)
.await
}
.await;
let accepted = finish(
inner,
input,
step_id,
purpose,
result,
cancellation,
item_acceptance,
)
.await?;
if !item_acceptance && let Some(tools) = tools {
// The successful request no longer needs its retry payload while tools run.
drop(retry.take());
use futures_util::FutureExt;
let mut step = tools.clone().into_step(&accepted.0);
for call in accepted
.1
.tool_calls()
.filter(|call| !call.provider_executed)
{
let result = std::panic::AssertUnwindSafe(step.invoke(call, cancellation.clone()))
.catch_unwind()
.await
.unwrap_or_else(|_| {
Err(maka_agent_facts::tools::ToolError::CleanupUnconfirmed(
"tool panicked".into(),
))
});
tool_result(result)?;
}
}
Ok(accepted)
}
pub(super) struct PreparedRequest {
pub context_breakdown: maka_agent_facts::context::PreparedContextBreakdown,
pub request: ModelRequest,
pub input_digest: String,
pub route_identity: String,
}
pub(super) fn route_identity<W>(input: &RunInput<W>) -> Result<String, RunError> {
digest(&input.provider)
}
pub(super) fn prepare_request<W>(
input: &RunInput<W>,
prompt: Vec<Message>,
definitions: Vec<ToolDefinition>,
max_output_tokens: Option<u64>,
purpose: ModelPurpose,
) -> Result<PreparedRequest, RunError> {
let prompt = if input.supports_vision
&& matches!(
&input.provider.kind,
maka_model::ProviderKind::OpenaiChatCompletions
| maka_model::ProviderKind::OpenaiCompatible { .. }
) {
history::project_chat(prompt, input.configuration.language)
} else {
prompt
};
let prompt = history::project_compatible(prompt, &input.provider.kind);
let prompt = maka_model::reasoning::project(prompt, &input.provider.kind);
let context_breakdown = crate::context_input::breakdown(&prompt, &definitions);
context_breakdown
.validate()
.map_err(|error| RunError::Internal(error.into()))?;
let max_output_tokens = Some(crate::context_input::output_limit(
input,
context_breakdown.estimated_tokens(),
max_output_tokens.unwrap_or(8000),
purpose,
));
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct Evidence<'a> {
projection: &'static str,
#[serde(skip_serializing_if = "Language::is_english")]
language: Language,
prompt: &'a [Message],
tools: &'a [ToolDefinition],
provider_options: &'a serde_json::Value,
#[serde(skip_serializing_if = "Option::is_none")]
max_output_tokens: Option<u64>,
}
let input_digest = digest(&Evidence {
projection: "maka.model-history.v1",
language: input.configuration.language,
prompt: &prompt,
tools: &definitions,
provider_options: &input.provider_options,
max_output_tokens,
})?;
Ok(PreparedRequest {
context_breakdown,
request: ModelRequest {
language: input.configuration.language,
provider: input.provider.clone(),
prompt,
tools: definitions,
provider_options: input.provider_options.clone(),
max_output_tokens,
},
input_digest,
route_identity: route_identity(input)?,
})
}
#[allow(clippy::too_many_arguments)]
async fn receive(
inner: &Arc<Inner>,
input: &RunContext,
step_id: &str,
purpose: ModelPurpose,
mut stream: maka_model::ModelStream,
mut tools: Option<maka_tools::StepTools<'_>>,
progressed: &mut bool,
cancellation: &CancellationToken,
retry: &mut Option<Vec<Message>>,
) -> Result<ModelStep, RunError> {
use futures_util::{FutureExt, StreamExt, stream::FuturesUnordered};
use maka_agent_facts::tools::ToolError;
let mut builder = StepBuilder::for_step(step_id)?;
let tool_cancellation = cancellation.child_token();
let mut running = FuturesUnordered::new();
let result: Result<_, RunError> = async {
loop {
tokio::select! {
biased;
result = running.next(), if !running.is_empty() => { tool_result(result.unwrap())?; }
event = stream.next() => {
let Some(event) = event else { break; };
let observations = ready_observations(&mut builder, event, || {
stream.next().now_or_never().flatten()
});
use maka_agent_facts::event::{EventWrite, RuntimeEvent};
let writes = observations.events.iter().map(|event| {
EventWrite::plain(RuntimeEvent::new(input.invocation.clone(), Fact::ModelObserved {
step_id: step_id.to_owned(), event: event.clone(),
}))
}).collect::<Result<Vec<_>, _>>()?;
if !writes.is_empty() {
inner.log.append_batch(&writes).await?;
}
drop(observations.reservations);
if tools.is_some() && observations.accepted_item {
*progressed = true;
// Any retry now rebuilds from accepted progress. Release
// the obsolete prompt before dispatching dependent work.
drop(retry.take());
}
// Every call's acceptance is durable before dispatch, even
// when a later ready event failed validation or cancelled.
for event in observations.events {
if let ModelEvent::ToolCall(call) = event
&& !call.provider_executed
&& let Some(tools) = tools.as_mut()
{
let future = tools.dispatch(call, tool_cancellation.clone());
running.push(async move {
std::panic::AssertUnwindSafe(future).catch_unwind().await
.unwrap_or_else(|_| Err(ToolError::CleanupUnconfirmed("tool panicked".into())))
});
}
}
if let Some(error) = observations.error {
return Err(error.into());
}
}
}
}
builder.finish().map_err(Into::into)
}.await;
let stream_cleanup = stream.cancel_and_wait().await;
if matches!(result, Err(ref error) if !matches!(error, RunError::Model(_))) {
tool_cancellation.cancel();
}
let mut cleanup = Ok(());
while let Some(result) = running.next().await {
if let Err(error) = tool_result(result) {
tool_cancellation.cancel();
if cleanup.is_ok() {
cleanup = Err(error);
}
}
}
stream_cleanup.map_err(|_| crate::cleanup::unconfirmed())?;
cleanup?;
let output = result?;
if purpose == ModelPurpose::Summary
&& output.parts.iter().any(|part| {
matches!(
part,
maka_agent_facts::model::ModelPart::ToolCall { .. }
| maka_agent_facts::model::ModelPart::ToolResult { .. }
)
})
{
return Err(maka_model::ModelError::Adapter("summary contains tool content".into()).into());
}
Ok(output)
}
struct ReadyObservations {
events: Vec<ModelEvent>,
reservations: Vec<Arc<tokio::sync::OwnedSemaphorePermit>>,
accepted_item: bool,
error: Option<ModelError>,
}
/// Take only the currently available prefix. A failed tail must not roll back
/// earlier valid observations or cause us to consume events beyond the failure.
fn ready_observations(
builder: &mut StepBuilder,
first: Result<maka_model::Emission, ModelError>,
mut next_ready: impl FnMut() -> Option<Result<maka_model::Emission, ModelError>>,
) -> ReadyObservations {
let mut observations = ReadyObservations {
events: Vec::new(),
reservations: Vec::new(),
accepted_item: false,
error: None,
};
let mut next = Some(first);
while let Some(event) = next {
match event.and_then(|emission| {
builder.push(emission.event.clone())?;
if let Some(reservation) = emission.reservation {
observations.reservations.push(reservation);
}
Ok(emission.event)
}) {
Ok(event) => {
observations.accepted_item |= builder.accepted_item().is_some();
// Provider chunk boundaries have no durable identity. Retain the
// existing 8 KiB text-observation budget after validating every
// original event, and never merge across metadata or other events.
if let (
Some(ModelEvent::PartDelta {
id,
text,
provider_options,
}),
ModelEvent::PartDelta {
id: next_id,
text: next_text,
provider_options: next_options,
},
) = (observations.events.last_mut(), &event)
&& id.as_str() == next_id.as_str()
&& provider_options.as_ref() == next_options.as_ref()
&& text.len().saturating_add(next_text.len()) <= 8 * 1024
{
text.push_str(next_text);
} else {
observations.events.push(event);
}
}
Err(error) => {
observations.error = Some(error);
break;
}
}
next = next_ready();
}
observations
}
fn tool_result(
result: Result<serde_json::Value, maka_agent_facts::tools::ToolError>,
) -> Result<(), RunError> {
use maka_agent_facts::tools::ToolError;
match result {
Ok(_)
| Err(
ToolError::Failed(_)
| ToolError::Io { .. }
| ToolError::OutcomeUnknown(_)
| ToolError::OperationUnknown { .. },
) => Ok(()),
Err(error) => Err(error.into()),
}
}
#[allow(clippy::too_many_arguments)]
async fn finish(
inner: &Arc<Inner>,
input: &RunContext,
step_id: String,
purpose: ModelPurpose,
result: Result<ModelStep, RunError>,
cancellation: &CancellationToken,
item_acceptance: bool,
) -> Result<(String, ModelStep), RunError> {
let output = match result {
Ok(output) => output,
Err(RunError::Model(error)) => {
let status = match error {
maka_model::ModelError::Cancelled => ModelInterruption::Cancelled,
maka_model::ModelError::TimedOut => ModelInterruption::TimedOut,
maka_model::ModelError::Adapter(_) | maka_model::ModelError::Http { .. } => {
ModelInterruption::Failed
}
maka_model::ModelError::ContextOverflow { .. } => ModelInterruption::Failed,
maka_model::ModelError::Provider(ref failure)
if failure.replay_safe()
|| (item_acceptance && failure.retained_output_safe()) =>
{
ModelInterruption::RetryableFailure
}
maka_model::ModelError::Provider(ref failure)
if failure.reason() == maka_model::ProviderFailureReason::TimedOut =>
{
ModelInterruption::TimedOut
}
maka_model::ModelError::Provider(_) => ModelInterruption::Failed,
};
append(
inner,
&input.invocation,
Fact::ModelInterrupted {
step_id: step_id.clone(),
status,
diagnostic: Some(error.to_string().chars().take(1024).collect()),
model_failure: error.failure_cause(),
},
)
.await?;
return Err(error.into());
}
Err(error) => return Err(error),
};
use maka_agent_facts::event::EventWrite;
use maka_agent_facts::event::RuntimeEvent;
let incomplete = purpose == ModelPurpose::Main
&& output.finish_reason == maka_agent_facts::model::ModelFinishReason::Length;
let mut writes = Vec::new();
if incomplete && !item_acceptance {
for call in output.tool_calls().filter(|call| !call.provider_executed) {
writes.push(EventWrite::plain(RuntimeEvent::new(
input.invocation.clone(),
Fact::ToolRejected {
operation_id: format!("{step_id}:{}", call.id),
call: maka_agent_facts::tool_call::ToolCallIdentity::provider(
step_id.clone(),
call.id.clone(),
),
name: call.name.clone(),
input: call.input.clone(),
reason: maka_agent_facts::tool_call::ToolRejection::PreparationFailed {
message: RunError::ModelIncomplete.to_string(),
},
},
))?);
}
}
writes.insert(
0,
EventWrite::plain(RuntimeEvent::new(
input.invocation.clone(),
Fact::ModelCompleted {
step_id: step_id.clone(),
output,
},
))?,
);
inner.log.append_batch(&writes).await?;
if incomplete {
return Err(RunError::ModelIncomplete);
}
if cancellation.is_cancelled() {
return Err(RunError::Cancelled);
}
let Fact::ModelCompleted { output, .. } = writes.remove(0).into_event().fact else {
unreachable!("first write is the model completion");
};
Ok((step_id, output))
}
#[cfg(test)]
mod tests {
use super::ready_observations;
use maka_agent_facts::model::{ModelFinishReason, ModelPart, ModelStep, TextKind};
use maka_model::{ModelError, ModelEvent, StepBuilder};
use serde_json::{Value, json};
fn started(id: &str) -> ModelEvent {
ModelEvent::PartStarted {
id: id.into(),
text_kind: TextKind::Text,
provider_options: None,
}
}
fn delta(id: &str, text: &str, provider_options: Option<Value>) -> ModelEvent {
ModelEvent::PartDelta {
id: id.into(),
text: text.into(),
provider_options,
}
}
fn closed(id: &str) -> ModelEvent {
ModelEvent::PartFinished {
id: id.into(),
provider_options: None,
}
}
fn replay(events: &[ModelEvent]) -> ModelStep {
let mut builder = StepBuilder::default();
for event in events {
builder.push(event.clone()).unwrap();
}
builder
.push(ModelEvent::Finished {
reason: ModelFinishReason::Stop,
usage: Default::default(),
provider_options: None,
})
.unwrap();
builder.finish().unwrap()
}
fn batch(events: &[ModelEvent]) -> super::ReadyObservations {
let mut ready = events.iter().cloned().map(|event| Ok(event.into()));
ready_observations(&mut StepBuilder::default(), ready.next().unwrap(), || {
ready.next()
})
}
#[tokio::test]
async fn coalesced_observations_retain_all_input_reservations_until_commit() {
use std::sync::Arc;
let budget = Arc::new(tokio::sync::Semaphore::new(2));
let mut builder = StepBuilder::default();
builder.push(started("text")).unwrap();
let first = maka_model::Emission {
event: delta("text", "one", None),
reservation: Some(Arc::new(budget.clone().acquire_owned().await.unwrap())),
};
let second = maka_model::Emission {
event: delta("text", " two", None),
reservation: Some(Arc::new(budget.clone().acquire_owned().await.unwrap())),
};
let mut second = Some(Ok(second));
let batch = ready_observations(&mut builder, Ok(first), || second.take());
assert_eq!(batch.events, [delta("text", "one two", None)]);
assert_eq!(
budget.available_permits(),
0,
"uncommitted observations own both inputs"
);
drop(batch);
assert_eq!(budget.available_permits(), 2);
// StepBuilder retains the answer, but no longer owns in-flight capacity.
builder.push(closed("text")).unwrap();
}
#[test]
fn ready_deltas_preserve_metadata_changes_and_other_event_boundaries() {
let original = vec![
started("text"),
delta(
"text",
"one",
Some(json!({"provider":{"signature":"same"}})),
),
delta(
"text",
" two",
Some(json!({"provider":{"signature":"same"}})),
),
delta(
"text",
" three",
Some(json!({"provider":{"signature":"changed"}})),
),
ModelEvent::ResponseMetadata {
id: Some("response".into()),
model: None,
timestamp: None,
},
delta(
"text",
" four",
Some(json!({"provider":{"signature":"changed"}})),
),
closed("text"),
];
let merged = batch(&original);
assert!(merged.error.is_none());
assert_eq!(
merged.events,
vec![
started("text"),
delta(
"text",
"one two",
Some(json!({"provider":{"signature":"same"}}))
),
original[3].clone(),
original[4].clone(),
original[5].clone(),
closed("text"),
]
);
let output = replay(&merged.events);
assert_eq!(output, replay(&original));
assert_eq!(
output.parts,
vec![ModelPart::Text {
text_kind: TextKind::Text,
text: "one two three four".into(),
provider_options: Some(json!({"provider":{"signature":"changed"}})),
}]
);
}
#[test]
fn ready_deltas_preserve_interleaved_parts() {
let original = vec![
started("a"),
started("b"),
delta("a", "first", None),
delta("b", "second", None),
delta("a", " third", None),
delta("a", " fourth", None),
closed("a"),
closed("b"),
];
let merged = batch(&original);
assert!(merged.error.is_none());
assert_eq!(
merged.events,
vec![
started("a"),
started("b"),
delta("a", "first", None),
delta("b", "second", None),
delta("a", " third fourth", None),
closed("a"),
closed("b"),
]
);
assert_eq!(replay(&merged.events), replay(&original));
}
#[test]
fn ready_deltas_keep_utf8_and_the_existing_text_byte_budget() {
for (first, expected_lengths) in [
("x".repeat(8188), vec![8192, 2]),
("x".repeat(8189), vec![8189, 6]),
] {
let original = vec![
started("text"),
delta("text", &first, None),
delta("text", "😀", None),
delta("text", "é", None),
closed("text"),
];
let merged = batch(&original);
assert!(merged.error.is_none());
let lengths: Vec<_> = merged
.events
.iter()
.filter_map(|event| match event {
ModelEvent::PartDelta { text, .. } => Some(text.len()),
_ => None,
})
.collect();
assert_eq!(lengths, expected_lengths);
assert_eq!(replay(&merged.events), replay(&original));
}
}
#[test]
fn ready_observations_keep_valid_prefix_before_invalid_tail() {
let valid = [
started("text"),
ModelEvent::PartDelta {
id: "text".into(),
text: "saved".into(),
provider_options: None,
},
delta("text", " prefix", None),
closed("text"),
];
let mut ready = valid[1..].iter().cloned().map(Ok).chain([
Ok(ModelEvent::PartFinished {
id: "unknown".into(),
provider_options: None,
}),
Ok(started("must-not-be-read")),
]);
let batch = ready_observations(
&mut StepBuilder::default(),
Ok(valid[0].clone().into()),
|| ready.next().map(|result| result.map(Into::into)),
);
assert_eq!(
batch.events,
[
started("text"),
delta("text", "saved prefix", None),
closed("text")
]
);
assert_eq!(replay(&batch.events), replay(&valid));
assert!(batch.accepted_item);
assert!(batch.error.is_some());
assert_eq!(ready.next().unwrap().unwrap(), started("must-not-be-read"));
}
#[test]
fn ready_observations_keep_prefix_on_cancellation_and_provider_failure() {
for error in [
ModelError::Cancelled,
ModelError::Adapter("provider failed".into()),
] {
let event = started("saved");
let mut next = Some(Err(error));
let batch = ready_observations(
&mut StepBuilder::default(),
Ok(event.clone().into()),
|| next.take(),
);
assert_eq!(batch.events, [event]);
assert!(batch.error.is_some());
assert!(!batch.accepted_item);
}
}
#[test]
fn ready_observations_flush_when_no_more_events_are_ready() {
let mut builder = StepBuilder::default();
let event = ModelEvent::Finished {
reason: ModelFinishReason::Stop,
usage: Default::default(),
provider_options: None,
};
let batch = ready_observations(&mut builder, Ok(event.clone().into()), || None);
assert_eq!(batch.events, [event]);
assert!(batch.error.is_none());
builder.finish().unwrap();
}
}