use locode_protocol::{ContentBlock, Event, Message, Report, ResultChunk, Role, ToolCallRecord};
use locode_provider::{Completion, CompletionDelta, ConversationRequest, ProviderError};
use locode_tools::{ToolCtx, ToolKind};
use serde_json::Value;
use tokio_util::sync::CancellationToken;
use crate::approve::{ApprovalRequest, Decision};
use crate::session::Session;
use crate::terminal::{RunAcc, Terminal};
enum SampleError {
Cancelled,
Provider(ProviderError),
}
impl Session {
fn refresh_project_instructions(&mut self) {
if !self.config.instructions.enabled {
return;
}
let discovered = locode_instructions::load_project_instructions(
&self.config.cwd,
&self.config.instructions,
);
let budget = self.config.instructions.byte_budget;
let message = match locode_instructions::render_body(&discovered, budget) {
Some(body) => {
if locode_instructions::already_delivered(&self.history, &body) {
return; }
let replace = locode_instructions::any_delivered(&self.history);
locode_instructions::render_instructions(&discovered, budget, replace)
}
None => {
if locode_instructions::any_delivered(&self.history)
&& !locode_instructions::removal_delivered(&self.history)
{
Some(locode_instructions::removal_message())
} else {
None
}
}
};
if let Some(msg) = message {
self.history.push(msg.clone());
self.sink.emit(Event::Message { message: msg });
}
}
pub(crate) fn rescan_skills(&mut self) {
if !self.config.skills.enabled {
return;
}
let discovered = locode_skills::discover(&self.config.cwd, &self.config.skills);
for warning in &discovered.warnings {
self.sink.emit(Event::Error {
message: warning.clone(),
});
}
let budget = locode_skills::char_budget(self.config.context_window_tokens);
self.skills_body = locode_skills::render_body(&discovered.skills, budget);
}
fn inject_skills(&mut self) {
if !self.config.skills.enabled {
return;
}
let message = if let Some(body) = self.skills_body.clone() {
if locode_skills::already_delivered(&self.history, &body) {
return;
}
locode_skills::listing_message(&body)
} else {
if !locode_skills::any_listing_delivered(&self.history)
|| locode_skills::already_delivered(&self.history, locode_skills::NO_SKILLS_BODY)
{
return;
}
locode_skills::removal_message()
};
self.history.push(message.clone());
self.sink.emit(Event::Message { message });
}
fn emit_init(&mut self) {
let tools: Vec<Value> = self
.registry
.specs()
.iter()
.filter_map(|spec| serde_json::to_value(spec).ok())
.collect();
self.sink.emit(Event::Init {
session_id: self.config.session_id.clone(),
harness: self.config.harness.clone(),
api_schema: self.config.api_schema.clone(),
model: self.config.model.clone(),
cwd: self.config.cwd.to_string_lossy().into_owned(),
max_turns: self.config.max_turns,
preamble: self.preamble.clone(),
tools,
});
}
pub(crate) async fn drive(&mut self, user_content: Vec<ContentBlock>) -> Report {
if self.turns_run == 0 {
self.emit_init();
}
self.turns_run += 1;
self.refresh_project_instructions();
if self.turns_run == 1 {
self.rescan_skills();
}
self.inject_skills();
let user_msg = Message {
role: Role::User,
content: user_content,
};
self.history.push(user_msg.clone());
self.sink.emit(Event::Message { message: user_msg });
let mut acc = RunAcc::default();
let terminal = loop {
if self.cancel.is_cancelled() {
break Terminal::Cancelled;
}
locode_provider::repair_pairing(&mut self.history);
let request = ConversationRequest {
messages: self.history.clone(),
tools: self.registry.specs(),
sampling_args: self.config.sampling_args.clone(),
cache_hint: self.config.cache_hint,
};
let completion = match self.sample_nonempty(request).await {
Ok(completion) => completion,
Err(SampleError::Cancelled) => break Terminal::Cancelled,
Err(SampleError::Provider(err)) => {
break Terminal::ModelError {
error: err.to_string(),
};
}
};
acc.turns += 1;
acc.usage += completion.usage;
acc.last_usage = completion.usage;
acc.last_stop = Some(stop_reason_str(&completion.stop));
let calls: Vec<(String, String, Value)> = completion
.content
.iter()
.filter_map(|block| match block {
ContentBlock::ToolUse { id, name, input } => {
Some((id.clone(), name.clone(), input.clone()))
}
_ => None,
})
.collect();
let truncated_call = truncated_tool_call(&completion);
let assistant_text = join_text(&completion.content);
acc.last_assistant_text = assistant_text.clone();
let assistant_msg = Message {
role: Role::Assistant,
content: completion.content,
};
self.history.push(assistant_msg.clone());
self.sink.emit(Event::Message {
message: assistant_msg,
});
if calls.is_empty() {
break Terminal::Completed {
final_message: assistant_text,
};
}
let (results, fatal) = self
.dispatch_batch(calls, &mut acc, truncated_call.as_deref())
.await;
let tool_msg = Message {
role: Role::User,
content: results,
};
self.history.push(tool_msg.clone());
self.sink.emit(Event::Message { message: tool_msg });
if let Some(error) = fatal {
break Terminal::Error { error };
}
if let Some(cap) = self.config.max_turns
&& acc.turns >= cap
{
break Terminal::MaxTurns;
}
};
let report = self.build_report(terminal, acc);
self.sink.emit(Event::Result {
report: report.clone(),
});
self.cancel = CancellationToken::new();
self.rescan_skills();
report
}
async fn dispatch_batch(
&mut self,
calls: Vec<(String, String, Value)>,
acc: &mut RunAcc,
truncated_call: Option<&str>,
) -> (Vec<ContentBlock>, Option<String>) {
let mut results: Vec<ContentBlock> = Vec::with_capacity(calls.len());
let mut fatal: Option<String> = None;
for (id, name, input) in calls {
if fatal.is_some() {
results.push(synthetic_error(
&id,
"tool not executed: a prior tool in this batch aborted the turn",
));
continue;
}
if self.cancel.is_cancelled() {
results.push(synthetic_error(
&id,
"tool not executed: the run was cancelled",
));
continue;
}
if truncated_call == Some(id.as_str()) {
results.push(synthetic_error(&id, TRUNCATED_TOOL_CALL));
continue;
}
let request = ApprovalRequest {
tool_use_id: &id,
tool_name: &name,
kind: self.registry.kind_of(&name),
input: &input,
};
let asked = std::time::Instant::now();
let decision = self.approver.decide(&request).await;
let wait_ms = u64::try_from(asked.elapsed().as_millis()).unwrap_or(u64::MAX);
self.sink.emit(Event::Approval {
tool_use_id: id.clone(),
tool_name: name.clone(),
decision: match &decision {
Decision::Allow => "allow".to_owned(),
Decision::Deny { .. } => "deny".to_owned(),
},
wait_ms,
});
if let Decision::Deny { reason } = decision {
results.push(synthetic_error(&id, &format!("tool call denied: {reason}")));
acc.tool_calls.push(denied_record(
&id,
&name,
&input,
self.registry.kind_of(&name),
reason,
));
continue;
}
let ctx = ToolCtx::new(
self.config.cwd.clone(),
id.clone(),
self.config.workspace_root.clone(),
self.cancel.clone(),
);
let dispatched = self.registry.dispatch(&name, input, &ctx).await;
results.push(dispatched.tool_result);
acc.tool_calls.push(dispatched.record);
if let Some(message) = dispatched.fatal {
fatal = Some(message);
}
}
(results, fatal)
}
async fn sample_nonempty(
&mut self,
request: ConversationRequest,
) -> Result<Completion, SampleError> {
let mut attempt: u32 = 0;
loop {
let completion = self.sample_with_retry(request.clone()).await?;
let empty = !completion.has_tool_calls() && completion.text().is_none();
if !empty {
return Ok(completion);
}
if attempt >= self.config.resample_retries {
return Err(SampleError::Provider(ProviderError::Decode(format!(
"model returned an empty completion (no text, no tool calls; \
stop: {}) after {attempt} resample(s)",
stop_reason_str(&completion.stop)
))));
}
attempt += 1;
self.sink.emit(Event::Error {
message: format!(
"empty completion (stop: {}); resample {attempt}/{}",
stop_reason_str(&completion.stop),
self.config.resample_retries
),
});
}
}
async fn sample_with_retry(
&mut self,
request: ConversationRequest,
) -> Result<Completion, SampleError> {
let cancel = self.cancel.clone();
let streaming = self.config.streaming;
let provider = std::sync::Arc::clone(&self.provider);
let mut attempt: u32 = 0;
loop {
let result = {
let sink = &mut self.sink;
let mut on_delta = |delta: CompletionDelta| {
if let CompletionDelta::Text(text) = delta {
sink.emit(Event::MessageDelta { text });
}
};
tokio::select! {
biased;
() = cancel.cancelled() => return Err(SampleError::Cancelled),
result = async {
if streaming {
provider.stream(&request, &mut on_delta).await
} else {
provider.complete(&request).await
}
} => result,
}
};
match result {
Ok(completion) => return Ok(completion),
Err(err) if err.retryable() && attempt < self.config.resample_retries => {
attempt += 1;
self.sink.emit(Event::Error {
message: format!(
"provider error (resample {attempt}/{}): {err}",
self.config.resample_retries
),
});
let backoff = self.config.resample_backoff * attempt;
if !backoff.is_zero() {
tokio::select! {
biased;
() = cancel.cancelled() => return Err(SampleError::Cancelled),
() = tokio::time::sleep(backoff) => {}
}
}
}
Err(err) => return Err(SampleError::Provider(err)),
}
}
}
fn build_report(&self, terminal: Terminal, acc: RunAcc) -> Report {
let status = terminal.status();
let (final_message, error) = match terminal {
Terminal::Completed { final_message } => (final_message, None),
Terminal::MaxTurns | Terminal::Cancelled => (acc.last_assistant_text, None),
Terminal::ModelError { error } | Terminal::Error { error } => (None, Some(error)),
};
Report {
schema_version: 1,
status,
harness: self.config.harness.clone(),
api_schema: self.config.api_schema.clone(),
final_message,
structured_output: None,
turns: acc.turns,
tool_calls: acc.tool_calls,
usage: acc.usage,
context_usage: acc.last_usage,
session_id: self.config.session_id.clone(),
stop_reason: acc.last_stop,
error,
}
}
}
fn stop_reason_str(stop: &locode_provider::StopReason) -> String {
use locode_provider::StopReason as S;
match stop {
S::EndTurn => "end_turn".to_string(),
S::MaxTokens => "max_tokens".to_string(),
S::ToolUse => "tool_use".to_string(),
S::StopSequence => "stop_sequence".to_string(),
S::Refusal => "refusal".to_string(),
S::PauseTurn => "pause_turn".to_string(),
S::Unknown(raw) => raw.clone(),
_ => "unknown".to_string(),
}
}
fn join_text(content: &[ContentBlock]) -> Option<String> {
let mut out = String::new();
for block in content {
if let ContentBlock::Text { text } = block {
if !out.is_empty() {
out.push('\n');
}
out.push_str(text);
}
}
(!out.is_empty()).then_some(out)
}
fn denied_record(
id: &str,
name: &str,
input: &Value,
kind: Option<ToolKind>,
reason: String,
) -> ToolCallRecord {
ToolCallRecord {
id: id.to_owned(),
name: name.to_owned(),
kind: kind.unwrap_or(ToolKind::Other).as_str().to_owned(),
args: input.clone(),
ok: false,
output: Value::Null,
denial_reason: Some(reason),
}
}
fn truncated_tool_call(completion: &locode_provider::Completion) -> Option<String> {
if !matches!(completion.stop, locode_provider::StopReason::MaxTokens) {
return None;
}
match completion.content.last() {
Some(ContentBlock::ToolUse { id, .. }) => Some(id.clone()),
_ => None,
}
}
const TRUNCATED_TOOL_CALL: &str = "tool not executed: the model reached its output-token limit \
(stop_reason: max_tokens) while writing this call, so the arguments arrived incomplete. \
Do not repeat the call unchanged — it will be cut off again. Re-issue it with a smaller \
payload, splitting the work across several calls (for a file write, write it in parts).";
fn synthetic_error(id: &str, message: &str) -> ContentBlock {
ContentBlock::ToolResult {
tool_use_id: id.to_owned(),
content: vec![ResultChunk::Text {
text: message.to_owned(),
}],
is_error: true,
}
}