use std::path::PathBuf;
use std::sync::Arc;
use tracing::warn;
use clawft_types::config::BindingThreadMode;
use crate::agent::identity::{
BINDING_THREAD_MISMATCH_REASON, Identity, IdentityError, IdentityProvider,
soul_contains_binding_thread,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BindingThreadStatus {
Ok,
Mismatch,
}
impl BindingThreadStatus {
pub const fn as_str(self) -> &'static str {
match self {
Self::Ok => "ok",
Self::Mismatch => "mismatch",
}
}
pub fn from_soul(soul: &str) -> Self {
if soul_contains_binding_thread(soul) {
Self::Ok
} else {
Self::Mismatch
}
}
pub const fn is_ok(self) -> bool {
matches!(self, Self::Ok)
}
}
#[derive(Debug, Clone)]
pub struct BuiltSystemPrompt {
pub body: String,
pub identity_source: String,
pub binding_thread_status: BindingThreadStatus,
}
pub struct SystemPromptBuilder {
identity_provider: Arc<dyn IdentityProvider>,
workspace: PathBuf,
binding_thread_mode: BindingThreadMode,
}
impl SystemPromptBuilder {
pub fn new(identity_provider: Arc<dyn IdentityProvider>, workspace: PathBuf) -> Self {
Self {
identity_provider,
workspace,
binding_thread_mode: BindingThreadMode::Deny,
}
}
pub fn with_binding_thread_mode(mut self, mode: BindingThreadMode) -> Self {
self.binding_thread_mode = mode;
self
}
pub fn binding_thread_mode(&self) -> BindingThreadMode {
self.binding_thread_mode
}
pub fn workspace(&self) -> &std::path::Path {
&self.workspace
}
pub async fn build_with_meta(&self) -> Result<BuiltSystemPrompt, IdentityError> {
let identity = self.identity_provider.current().await?;
let status = BindingThreadStatus::from_soul(&identity.soul);
if status == BindingThreadStatus::Mismatch && self.binding_thread_mode.is_deny() {
warn!(
hash = %identity.hash,
source = %identity.source,
reason = BINDING_THREAD_MISMATCH_REASON,
"binding-thread mismatch: SOUL.md does not contain the \
compile-time BINDING_THREAD_EXCERPT — hard refusing turn \
(agents.binding_thread_mode=deny)"
);
return Err(IdentityError::BindingThreadMismatch);
}
Ok(BuiltSystemPrompt {
body: self.render_with_status(&identity, status),
identity_source: identity.source.as_str().to_string(),
binding_thread_status: status,
})
}
pub async fn build(&self) -> Result<String, IdentityError> {
Ok(self.build_with_meta().await?.body)
}
pub fn render(&self, identity: &Identity) -> String {
let status = BindingThreadStatus::from_soul(&identity.soul);
self.render_with_status(identity, status)
}
fn render_with_status(&self, identity: &Identity, status: BindingThreadStatus) -> String {
if status == BindingThreadStatus::Mismatch {
warn!(
hash = %identity.hash,
source = %identity.source,
"binding-thread mismatch: SOUL.md does not contain the \
compile-time BINDING_THREAD_EXCERPT — running in \
degraded mode (agents.binding_thread_mode=warn_only)"
);
}
let mut s = String::with_capacity(identity.soul.len() + identity.identity.len() + 512);
s.push_str("[identity]\n");
s.push_str(&identity.soul);
if !identity.soul.ends_with('\n') {
s.push('\n');
}
s.push('\n');
s.push_str(&identity.identity);
if !identity.identity.ends_with('\n') {
s.push('\n');
}
s.push('\n');
s.push_str("[binding-thread-status]\n");
s.push_str(status.as_str());
s.push('\n');
if status == BindingThreadStatus::Mismatch {
s.push_str(
"note: SOUL.md does not match the compile-time \
binding-thread excerpt; running in degraded mode.\n",
);
}
s.push('\n');
if !identity.pending_journal.is_empty() {
s.push_str("[soul-journal-pending]\n");
s.push_str(&format!("count: {}\n", identity.pending_journal.len()));
for entry in &identity.pending_journal {
s.push_str("- ");
s.push_str(&entry.summary);
s.push('\n');
}
s.push('\n');
}
s.push_str("[workspace]\n");
s.push_str(&self.workspace.display().to_string());
s.push('\n');
s.push('\n');
s.push_str("[hash]\n");
s.push_str(&identity.hash);
s.push('\n');
s
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::identity::{
BINDING_THREAD_EXCERPT, FileIdentityProvider, IdentitySource,
};
use async_trait::async_trait;
struct StubProvider(Identity);
#[async_trait]
impl IdentityProvider for StubProvider {
async fn current(&self) -> Result<Identity, IdentityError> {
Ok(self.0.clone())
}
}
struct FailingProvider;
#[async_trait]
impl IdentityProvider for FailingProvider {
async fn current(&self) -> Result<Identity, IdentityError> {
Err(IdentityError::NotFound)
}
}
fn ok_identity() -> Identity {
let soul = format!(
"# SOUL.md\n\nWe carry the binding thread: \
{BINDING_THREAD_EXCERPT}.\n"
);
let identity = "# IDENTITY.md\n\nI am clawft.".to_string();
Identity::from_files(soul, identity, IdentitySource::Stub)
}
fn mismatch_identity() -> Identity {
let soul = "# SOUL.md\n\nNothing distinctive in here.\n".to_string();
let identity = "# IDENTITY.md\n\nI am clawft.".to_string();
Identity::from_files(soul, identity, IdentitySource::Stub)
}
#[tokio::test]
async fn build_includes_soul_identity_workspace_and_hash() {
let id = ok_identity();
let expected_soul = id.soul.clone();
let expected_identity = id.identity.clone();
let expected_hash = id.hash.clone();
let provider = Arc::new(StubProvider(id));
let builder = SystemPromptBuilder::new(provider, PathBuf::from("/tmp/ws"));
let prompt = builder.build().await.expect("build ok");
assert!(prompt.contains("[identity]"));
assert!(prompt.contains(expected_soul.trim()));
assert!(prompt.contains(expected_identity.trim()));
assert!(prompt.contains("[binding-thread-status]\nok"));
assert!(prompt.contains("[workspace]\n/tmp/ws"));
assert!(prompt.contains("[hash]\n"));
assert!(prompt.contains(&expected_hash));
}
#[tokio::test]
async fn build_with_meta_exposes_identity_source_and_status() {
let id = ok_identity();
assert_eq!(id.source, IdentitySource::Stub);
let provider = Arc::new(StubProvider(id));
let builder = SystemPromptBuilder::new(provider, PathBuf::from("/tmp/ws"));
let built = builder.build_with_meta().await.expect("build ok");
assert_eq!(built.identity_source, "stub");
assert!(built.body.contains("[identity]"));
assert_eq!(built.binding_thread_status, BindingThreadStatus::Ok);
}
#[tokio::test]
async fn deny_mode_hard_refuses_on_mismatch() {
let provider = Arc::new(StubProvider(mismatch_identity()));
let builder = SystemPromptBuilder::new(provider, PathBuf::from("/tmp/ws"));
assert!(builder.binding_thread_mode().is_deny());
let err = builder.build().await.unwrap_err();
assert!(matches!(err, IdentityError::BindingThreadMismatch));
assert!(err.to_string().contains("binding-thread mismatch"));
}
#[tokio::test]
async fn warn_only_mode_annotates_mismatch() {
let provider = Arc::new(StubProvider(mismatch_identity()));
let builder = SystemPromptBuilder::new(provider, PathBuf::from("/tmp/ws"))
.with_binding_thread_mode(BindingThreadMode::WarnOnly);
let built = builder.build_with_meta().await.expect("warn_only continues");
assert_eq!(built.binding_thread_status, BindingThreadStatus::Mismatch);
assert!(built.body.contains("[binding-thread-status]\nmismatch"));
assert!(built.body.contains("degraded mode"));
}
#[tokio::test]
async fn build_propagates_provider_error() {
let provider = Arc::new(FailingProvider);
let builder = SystemPromptBuilder::new(provider, PathBuf::from("/tmp"));
let err = builder.build().await.unwrap_err();
assert!(matches!(err, IdentityError::NotFound));
}
#[tokio::test]
async fn build_surfaces_pending_journal_section() {
use crate::agent::soul_journal::PendingJournalEntry;
let mut id = ok_identity();
id.source = IdentitySource::Journal;
id.pending_journal = vec![PendingJournalEntry {
entry_id: "01HZ".into(),
summary: "prefer terse replies".into(),
content: "user wants shorter answers".into(),
ts: "2026-01-01T00:00:00Z".into(),
conv_id: "c1".into(),
signal: "user_correction".into(),
}];
let provider = Arc::new(StubProvider(id));
let builder = SystemPromptBuilder::new(provider, PathBuf::from("/tmp/ws"));
let built = builder.build_with_meta().await.expect("build ok");
assert_eq!(built.identity_source, "journal");
assert!(built.body.contains("[soul-journal-pending]"));
assert!(built.body.contains("count: 1"));
assert!(built.body.contains("prefer terse replies"));
}
#[test]
fn binding_status_helper_matches_constant() {
let with_excerpt = format!("prefix\n{BINDING_THREAD_EXCERPT}\nsuffix");
assert_eq!(
BindingThreadStatus::from_soul(&with_excerpt),
BindingThreadStatus::Ok
);
assert_eq!(
BindingThreadStatus::from_soul("nothing relevant"),
BindingThreadStatus::Mismatch
);
}
#[tokio::test]
async fn integrates_with_file_identity_provider() {
let tmp = tempfile::tempdir().unwrap();
let clawft = tmp.path().join(".clawft");
std::fs::create_dir_all(&clawft).unwrap();
std::fs::write(
clawft.join("SOUL.md"),
format!("# SOUL\n{BINDING_THREAD_EXCERPT}\n"),
)
.unwrap();
std::fs::write(clawft.join("IDENTITY.md"), "# IDENTITY\nclawft").unwrap();
let platform = Arc::new(clawft_platform::NativePlatform::new());
let provider: Arc<dyn IdentityProvider> =
Arc::new(FileIdentityProvider::new(tmp.path(), platform));
let builder = SystemPromptBuilder::new(provider, tmp.path().to_path_buf());
let prompt = builder.build().await.expect("build ok");
assert!(prompt.contains("[binding-thread-status]\nok"));
assert!(prompt.contains(&tmp.path().display().to_string()));
}
}