use super::knowledge_event::knowledge_supplied_event;
use super::tools;
#[cfg(test)]
pub(crate) use super::turn_config::query_data_allowed;
pub(crate) use super::turn_config::{
AgentRuntimeError, PromptOverrides, effective_ai, query_data_allowed_for_endpoint,
};
use super::turn_inputs::{TurnInputs, prepare_turn};
use crate::interactive::session_universe::SessionUniverse;
use crate::{
config::runtime::RuntimeConfig, grant_token::TurnPrimary, prompt_approval::TerminalApproval,
};
use saya_agent::{
AgentError, AgentEvent, AgentEventSink, AgentLimits, AgentMode, AgentOutput, AgentRequest,
ApprovalDecider, ApprovalPolicy, CancellationToken, ChatMessage, LocalStateEffect,
run_agent_with_sink,
};
use saya_store::SqliteStateStore;
use std::sync::Arc;
#[allow(clippy::too_many_arguments)]
pub(crate) async fn run_prompt_with_sink(
runtime: &RuntimeConfig,
prompt: &str,
approval: ApprovalPolicy,
can_prompt: bool,
can_obtain_approval: bool,
overrides: PromptOverrides,
history: Vec<ChatMessage>,
sink: &dyn AgentEventSink,
cancellation: CancellationToken,
state_db: Option<SqliteStateStore>,
decider: Option<Arc<dyn ApprovalDecider>>,
last_sql: Option<String>,
session: Option<Arc<SessionUniverse>>,
agent_mode: AgentMode,
) -> Result<AgentOutput, AgentRuntimeError> {
let inputs = prepare_turn(runtime, &overrides, can_prompt)
.await
.map_err(with_next_step)?;
run_prompt_with_inputs(
runtime,
inputs,
prompt,
approval,
can_prompt,
can_obtain_approval,
history,
sink,
cancellation,
state_db,
decider,
last_sql,
session,
agent_mode,
)
.await
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn run_prompt_with_inputs(
runtime: &RuntimeConfig,
inputs: TurnInputs,
prompt: &str,
approval: ApprovalPolicy,
can_prompt: bool,
can_obtain_approval: bool,
history: Vec<ChatMessage>,
sink: &dyn AgentEventSink,
cancellation: CancellationToken,
state_db: Option<SqliteStateStore>,
decider: Option<Arc<dyn ApprovalDecider>>,
last_sql: Option<String>,
session: Option<Arc<SessionUniverse>>,
agent_mode: AgentMode,
) -> Result<AgentOutput, AgentRuntimeError> {
let ai = inputs.ai;
let provider = inputs.provider;
let registry = inputs.registry;
let allow_query_data =
query_data_allowed_for_endpoint(ai.provider, ai.base_url.as_deref(), ai.allow_data_sharing);
let fallback_primary = TurnPrimary::default();
if let Some(session) = session.as_ref() {
session.primary.bind(®istry);
} else {
fallback_primary.bind(®istry);
}
for (name, reason) in inputs.failures {
sink.emit(AgentEvent::assistant_text(format!(
"skipped database '{name}': {reason}\n"
)))
.await;
}
let root;
let facts = match session.as_ref() {
Some(universe) => {
root = universe.root().map(std::path::Path::to_path_buf);
super::session_facts::SessionFacts {
registry: ®istry,
workspace_root: root.as_deref(),
}
}
None => super::session_facts::SessionFacts {
registry: ®istry,
workspace_root: None,
},
};
let reachable = super::system_prompt::memory_reachable(state_db.is_some(), allow_query_data);
let empty_prompt = super::system_prompt::assemble_system_prompt(
®istry,
runtime.resolved.memory.mode,
reachable,
);
let system_prompt = super::system_prompt::assemble_system_prompt_for_mode(
®istry,
runtime.resolved.memory.mode,
reachable,
&facts,
agent_mode,
);
debug_assert!(
facts.workspace_root.is_some()
|| agent_mode != saya_agent::AgentMode::Build
|| system_prompt == empty_prompt
);
let profile_names: Vec<String> = registry.names().into_iter().map(str::to_string).collect();
let memory = &runtime.resolved.memory;
let recall_mode = super::learning::recall_mode_for(memory.mode);
let (mut context_blocks, receipt) = match recall_mode {
None => (
Vec::new(),
crate::contracts::RecallReceipt::configured_off(),
),
Some(_) if !allow_query_data => (
Vec::new(),
crate::contracts::RecallReceipt::privacy_gate_closed(),
),
Some(mode) => {
super::recall_context::recall_context_blocks(
prompt,
system_prompt.as_deref(),
allow_query_data,
mode,
super::learning::bounds_from(memory),
®istry,
state_db.as_ref(),
runtime.resolved.ai.context_byte_budget,
)
.await
}
};
if let Some(hint) = last_sql
.as_deref()
.and_then(super::system_prompt::last_sql_hint_block)
{
context_blocks.push(hint);
}
if let Some(session) = session.as_ref()
&& let Some(block) = super::super::interactive::session_tasks_render::render_tasks_block(
&session.tasks().current(),
)
{
context_blocks.push(block);
}
sink.emit(knowledge_supplied_event(&receipt)).await;
let receipt = Arc::new(receipt);
let learning = super::learning::LearningSetup::from(memory.mode);
let observations_log = learning.observations.clone();
let has_state_store = state_db.is_some();
let override_log = Arc::new(tools::OverrideLog::new());
let database = Arc::new(
tools::DatabaseTools::with_learning(
registry,
runtime.resolved.max_rows,
allow_query_data,
state_db,
learning.observations,
)
.with_supplied_objects(receipt.supplied.iter().map(|c| c.object.clone()).collect())
.with_recall_receipt(Some(receipt.clone()), Some(override_log.clone()))
.with_workspace(session.as_ref().and_then(|session| session.workspace())),
);
let request = AgentRequest {
prompt: prompt.into(),
profile_names,
model: ai.model.clone(),
system_prompt,
history,
context_blocks,
};
let fallback_approval = TerminalApproval::new(
approval,
can_prompt,
fallback_primary,
crate::approval_facts::ApprovalFacts::for_ask(runtime),
);
let approver: &dyn ApprovalDecider = match decider.as_deref() {
Some(decider) => decider,
None => &fallback_approval,
};
let env_budgets = saya_agent::budgets_from_env(|name| std::env::var(name).ok());
let definitions = match session.as_ref() {
Some(session) => session.definitions(
agent_mode,
approval,
can_obtain_approval,
allow_query_data,
has_state_store,
learning.permit_candidate_writes,
),
None => tools::DatabaseTools::definitions(
allow_query_data,
has_state_store,
learning.permit_candidate_writes,
false,
agent_mode == AgentMode::Build
&& match approval {
ApprovalPolicy::Ask => can_obtain_approval,
ApprovalPolicy::Bypass => true,
_ => false,
},
),
};
let permit_workspace_writes = definitions
.iter()
.any(|definition| definition.effect.local_state == LocalStateEffect::WriteWorkspace);
let limits = AgentLimits {
max_turns: env_budgets.max_turns,
max_tool_calls: env_budgets.max_tool_calls,
max_continuations: env_budgets.max_continuations,
permit_candidate_writes: learning.permit_candidate_writes,
context_byte_budget: runtime.resolved.ai.context_byte_budget,
permit_workspace_writes,
permit_external_effects: false,
};
let executor: Arc<dyn saya_agent::ToolExecutor> = match session.as_ref() {
Some(session) => session.executor(
Arc::clone(&database),
&cancellation,
permit_workspace_writes,
),
None => Arc::clone(&database) as Arc<dyn saya_agent::ToolExecutor>,
};
let mut output = run_agent_with_sink(
&*provider,
executor.as_ref(),
request,
definitions,
limits,
approver,
sink,
cancellation,
)
.await;
let overridden = override_log.drain();
if !overridden.is_empty() {
sink.emit(AgentEvent::knowledge_overridden(overridden.clone()))
.await;
}
let fresh_breaker;
let breaker: &super::learning::LearningBreaker = match session.as_ref() {
Some(session) => session.learning_breaker(),
None => {
fresh_breaker = super::learning::LearningBreaker::new();
&fresh_breaker
}
};
let learning_usage = super::learning::post_turn::run_post_turn_extraction(
super::learning::post_turn::PostTurnInputs {
permit_candidate_writes: learning.permit_candidate_writes,
database: &database,
output: output.as_ref(),
prompt,
observations_log: observations_log.as_deref(),
receipt: &receipt,
overridden: &overridden,
provider: &*provider,
model: &ai.model,
breaker,
},
sink,
)
.await;
if let Ok(out) = output.as_mut() {
out.learning_usage = learning_usage;
}
output.map_err(|error| match error {
AgentError::Provider(error) => AgentRuntimeError::Provider(provider_message(&error)),
AgentError::Limit(error) => {
AgentRuntimeError::Agent(format!("agent limit reached: {error}"))
}
AgentError::InvalidToolCall => {
AgentRuntimeError::Agent("provider returned an unsupported tool call".into())
}
AgentError::InvalidHistory => {
AgentRuntimeError::Agent("conversation history is invalid".into())
}
AgentError::Cancelled => AgentRuntimeError::Agent("request cancelled".into()),
})
}
fn provider_message(error: &saya_agent::ProviderError) -> String {
let message = error.to_string();
if message.contains("could not reach the provider") {
format!(
"{message}\nnext: start your AI provider, or run `saya config doctor` to check \
the endpoint and base_url."
)
} else {
message
}
}
fn with_next_step(error: AgentRuntimeError) -> AgentRuntimeError {
match error {
AgentRuntimeError::Database(message)
if message.contains("secret reference")
&& message.contains("could not be resolved") =>
{
AgentRuntimeError::Database(format!(
"{message}\nnext: set the referenced environment variable (or add it to a \
.env.saya file passed with --env-file), then re-run; `saya config doctor` \
lists unresolved secrets."
))
}
other => other,
}
}
#[cfg(test)]
#[path = "runtime_tests.rs"]
mod tests;