use super::session_facts::{SessionFacts, session_facts_text};
use crate::connection::ConnectionRegistry;
use saya_agent::AgentMode;
use saya_config::MemoryMode;
pub(crate) const PLAN_SYSTEM_PROMPT: &str = "You are in Plan mode: investigate and answer with a plan. \
Write-shaped tools are absent from your tool list and would refuse if called; \
do not promise edits as if you had made them — describe the change you would make instead. \
Plan composes with the approval policy and never widens it.";
pub(crate) const MEMORY_SYSTEM_PROMPT: &str = "\
SAYA maintains durable knowledge about the user's databases across sessions. \
Confirmed facts relevant to the question are already supplied in context; there is no need to fetch them. \
The `contract_search` and `contract_read` tools are available for objects discovered mid-turn that were not in the supplied set. \
When the user states something durable about their data — what a word means, which column counts, what a table's grain is — restate it explicitly and precisely in the answer. What SAYA records is drawn from the turn, so a vague restatement is recorded vaguely.";
pub(crate) fn memory_section(mode: MemoryMode) -> Option<&'static str> {
match mode {
MemoryMode::Assisted => Some(MEMORY_SYSTEM_PROMPT),
_ => None,
}
}
fn naming_section(registry: &ConnectionRegistry) -> Option<String> {
let mut forms: Vec<(&str, &str)> = Vec::new();
for dialect in registry.dialects() {
let entry = (dialect.as_str(), dialect.qualified_name_form());
if !forms.contains(&entry) {
forms.push(entry);
}
}
match forms.as_slice() {
[] => None,
[(engine, form)] => Some(format!(
"The SQL dialect is {engine}'s, whatever the DDL says — the declared column \
types in this database may come from another engine. Name objects as `{form}` \
when writing SQL — the fullest form this engine accepts; an under-qualified \
name cannot be recorded against a real object and an over-qualified one is a \
syntax error."
)),
many => {
let list = many
.iter()
.map(|(engine, form)| format!("- {engine}: `{form}`"))
.collect::<Vec<_>>()
.join("\n");
Some(format!(
"Name objects with the fullest form the target engine accepts, and no more \
— an under-qualified name cannot be recorded against a real object, and an \
over-qualified one is a syntax error:\n{list}\nThe SQL dialect is the \
connected engine's, whatever the DDL says — the declared column types in a \
database may come from another engine."
))
}
}
}
const WORKING_GUIDANCE: &str = "Multi-step work is expected. Do not repeat an attempt that already \
failed — change your approach instead. When working with a database, discover the schema before \
you query it. When the question cannot be answered from what is available in this session, stop \
and say so, explaining what you tried and what is missing: a missing table or column, data that \
is not present, or a question the schema cannot express. Giving up with a reason is a correct \
outcome; looping is not.";
const ANSWER_CONTRACT: &str = "Answer the question exactly as asked. These rules govern answers that report query results, and leave other answers untouched:\n\
- Return only the columns the question asks for; drop intermediate working columns.\n\
- Do not round unless asked.\n\
- Write dates as ISO YYYY-MM-DD.\n\
- If the question asks for a ratio, a percentage, or a difference, compute that value and answer with it — returning the operands alone stops one step short.\n\
- A superlative — \"the fastest\", \"the highest\", \"the top one\", \"the fewest\" — asks which one: answer with that row and the value that makes it so, not the ranking it came from. Every row tied with it is part of the answer.\n\
- Answer every quantity the question names; if it asks for two things, answer both.\n\
- Read measure words literally: \"volume\" is units, \"revenue\" is money.\n\
- A qualifier on a metric is not a qualifier on the population — filter the metric, not the rows.\n\
- Keep every row tied at a cut-off; never drop a tie to fit a limit.\n\
- When a period is named, enumerate that whole period, not only the rows that happen to appear in the data.";
pub(crate) use super::turn_context::{last_sql_hint_block, memory_reachable};
pub(crate) fn assemble_system_prompt(
registry: &ConnectionRegistry,
memory_mode: MemoryMode,
memory_reachable: bool,
) -> Option<String> {
let empty = SessionFacts {
registry,
workspace_root: None,
};
assemble_system_prompt_with_session(registry, memory_mode, memory_reachable, &empty)
}
pub(crate) fn assemble_system_prompt_with_session(
registry: &ConnectionRegistry,
memory_mode: MemoryMode,
memory_reachable: bool,
session: &SessionFacts<'_>,
) -> Option<String> {
let base = registry.describe_context();
let memory = if memory_reachable {
memory_section(memory_mode)
} else {
None
};
let mut sections = Vec::new();
if let Some(b) = base {
sections.push(b);
}
if let Some(m) = memory {
sections.push(m.to_string());
}
if let Some(f) = session_facts_text(session) {
sections.push(f);
}
sections.push(WORKING_GUIDANCE.to_string());
sections.push(ANSWER_CONTRACT.to_string());
if let Some(n) = naming_section(registry) {
sections.push(n);
}
if sections.is_empty() {
None
} else {
Some(sections.join("\n\n"))
}
}
pub(crate) fn assemble_system_prompt_for_mode(
registry: &ConnectionRegistry,
memory_mode: MemoryMode,
memory_reachable: bool,
session: &SessionFacts<'_>,
agent_mode: AgentMode,
) -> Option<String> {
let prompt =
assemble_system_prompt_with_session(registry, memory_mode, memory_reachable, session)?;
match agent_mode {
AgentMode::Build => Some(prompt),
AgentMode::Plan => Some(format!("{prompt}\n\n{PLAN_SYSTEM_PROMPT}")),
}
}
#[cfg(test)]
#[path = "system_prompt_tests.rs"]
mod tests;