mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-30 06:50:16 +00:00
863 lines
29 KiB
Rust
863 lines
29 KiB
Rust
use std::path::Path;
|
|
use std::sync::Arc;
|
|
|
|
use async_trait::async_trait;
|
|
use fabro_graphviz::graph::{Graph, Node};
|
|
use fabro_types::{StageModelUsage, StageTiming};
|
|
|
|
use super::agent::{
|
|
CodergenBackend, CodergenResult, OneShotRequest, emit_stage_prompt, extract_status_fields,
|
|
truncate,
|
|
};
|
|
use super::llm::routing;
|
|
use super::{EngineServices, Handler, structured_output};
|
|
use crate::context::{Context, WorkflowContext, keys};
|
|
use crate::error::Error;
|
|
use crate::event::{Emitter, Event};
|
|
use crate::outcome::Outcome;
|
|
|
|
/// Handler for single-shot LLM calls (no tools, no agent loop).
|
|
pub struct PromptHandler {
|
|
backend: Option<Box<dyn CodergenBackend>>,
|
|
}
|
|
|
|
impl PromptHandler {
|
|
#[must_use]
|
|
pub fn new(backend: Option<Box<dyn CodergenBackend>>) -> Self {
|
|
Self { backend }
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Handler for PromptHandler {
|
|
async fn shutdown(&self, emitter: &Arc<Emitter>) {
|
|
if let Some(backend) = self.backend.as_ref() {
|
|
backend.shutdown(emitter).await;
|
|
}
|
|
}
|
|
|
|
async fn simulate(
|
|
&self,
|
|
node: &Node,
|
|
_context: &Context,
|
|
_graph: &Graph,
|
|
_run_dir: &Path,
|
|
_services: &EngineServices,
|
|
) -> Result<Outcome, Error> {
|
|
Ok(super::agent::simulate_llm_handler(node))
|
|
}
|
|
|
|
async fn execute(
|
|
&self,
|
|
node: &Node,
|
|
context: &Context,
|
|
_graph: &Graph,
|
|
_run_dir: &Path,
|
|
services: &EngineServices,
|
|
) -> Result<Outcome, Error> {
|
|
// 1. Build prompt (prepend fidelity preamble if present)
|
|
let raw_prompt = node.prompt_or_label();
|
|
let preamble = context.preamble();
|
|
let prompt = if preamble.is_empty() {
|
|
raw_prompt.to_string()
|
|
} else {
|
|
format!("{preamble}\n\n{raw_prompt}")
|
|
};
|
|
|
|
// 1b. Discover project docs for system prompt when project_memory is enabled
|
|
let system_prompt = if node.project_memory() {
|
|
let working_dir = services.run.sandbox.working_directory();
|
|
let profile_kind = routing::resolve_node_provider_context(
|
|
services.run.catalog.as_ref(),
|
|
&services.run.provider_id,
|
|
&services.run.model,
|
|
node,
|
|
)?
|
|
.profile_kind;
|
|
let docs = match fabro_agent::discover_memory(
|
|
&*services.run.sandbox,
|
|
working_dir,
|
|
working_dir,
|
|
profile_kind,
|
|
&services.run.cancel_token(),
|
|
)
|
|
.await
|
|
{
|
|
Ok(docs) => docs,
|
|
Err(fabro_agent::Error::Interrupted(fabro_agent::InterruptReason::Cancelled)) => {
|
|
return Err(Error::Cancelled);
|
|
}
|
|
Err(_) => Vec::new(),
|
|
};
|
|
|
|
if docs.is_empty() {
|
|
None
|
|
} else {
|
|
Some(
|
|
docs.into_iter()
|
|
.map(|doc| doc.content)
|
|
.collect::<Vec<_>>()
|
|
.join("\n\n"),
|
|
)
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let stage_scope = emit_stage_prompt(
|
|
services,
|
|
context,
|
|
node,
|
|
&prompt,
|
|
StageModelUsage::MODE_PROMPT,
|
|
self.backend.as_deref(),
|
|
)?;
|
|
|
|
// 3. Call LLM backend (one_shot)
|
|
let (response_text, stage_usage, backend_files_touched, timing) =
|
|
if let Some(backend) = &self.backend {
|
|
let result = backend
|
|
.one_shot(OneShotRequest {
|
|
node,
|
|
prompt: &prompt,
|
|
system_prompt: system_prompt.as_deref(),
|
|
emitter: &services.run.emitter,
|
|
stage_scope: &stage_scope,
|
|
sandbox: &services.run.sandbox,
|
|
cancel_token: services.run.cancel_token(),
|
|
})
|
|
.await;
|
|
match result {
|
|
Ok(CodergenResult::Full(outcome)) => return Ok(*outcome),
|
|
Ok(CodergenResult::Text {
|
|
text,
|
|
usage,
|
|
files_touched,
|
|
timing,
|
|
..
|
|
}) => (text, usage, files_touched, timing),
|
|
Err(Error::Cancelled) => return Err(Error::Cancelled),
|
|
Err(e) if e.is_retryable() => {
|
|
return Err(e);
|
|
}
|
|
Err(e) => {
|
|
return Ok(e.to_fail_outcome());
|
|
}
|
|
}
|
|
} else {
|
|
(
|
|
format!("[Simulated] Response for stage: {}", node.id),
|
|
None,
|
|
Vec::new(),
|
|
StageTiming::default(),
|
|
)
|
|
};
|
|
|
|
let response_model = stage_usage
|
|
.as_ref()
|
|
.map(|usage| usage.model_id().to_string())
|
|
.or_else(|| node.model().map(String::from))
|
|
.unwrap_or_default();
|
|
let response_provider = node
|
|
.provider()
|
|
.map(String::from)
|
|
.or_else(|| Some(services.run.provider_id.to_string()))
|
|
.unwrap_or_default();
|
|
|
|
services.run.emitter.emit_scoped(
|
|
&Event::PromptCompleted {
|
|
node_id: node.id.clone(),
|
|
response: response_text.clone(),
|
|
model: response_model,
|
|
provider: response_provider,
|
|
billing: stage_usage.clone(),
|
|
},
|
|
&stage_scope,
|
|
);
|
|
|
|
// 4. Build and write status
|
|
let mut outcome = Outcome::success();
|
|
outcome.notes = Some(format!("Stage completed: {}", node.id));
|
|
outcome
|
|
.context_updates
|
|
.insert(keys::LAST_STAGE.to_string(), serde_json::json!(node.id));
|
|
outcome.context_updates.insert(
|
|
keys::LAST_RESPONSE.to_string(),
|
|
serde_json::json!(truncate(&response_text, 200)),
|
|
);
|
|
outcome.context_updates.insert(
|
|
keys::response_key(&node.id),
|
|
serde_json::json!(&response_text),
|
|
);
|
|
|
|
if let Some(schema) = structured_output::parse_node_output_schema(node)? {
|
|
if let Ok(validated) =
|
|
structured_output::validate_response_text(&schema, &response_text)
|
|
{
|
|
structured_output::apply_validated_output(node, &schema, &validated, &mut outcome);
|
|
} else {
|
|
let mut failed =
|
|
structured_output::exhausted_failure_outcome(node.output_retries());
|
|
failed.timing = Some(timing);
|
|
failed.usage = stage_usage;
|
|
failed.files_touched = backend_files_touched;
|
|
return Ok(failed);
|
|
}
|
|
} else {
|
|
extract_status_fields(&response_text, &mut outcome);
|
|
}
|
|
outcome.usage = stage_usage;
|
|
outcome.files_touched = backend_files_touched;
|
|
outcome.timing = Some(timing);
|
|
|
|
Ok(outcome)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use fabro_graphviz::graph::AttrValue;
|
|
use fabro_model::{ReasoningEffort, Speed};
|
|
use fabro_store::{Database, RunDatabase, StageId};
|
|
use fabro_types::{fixtures, test_support};
|
|
use object_store::memory::InMemory;
|
|
use tempfile::TempDir;
|
|
|
|
use super::*;
|
|
use crate::event::Emitter;
|
|
use crate::handler::agent::CodergenRunRequest;
|
|
use crate::outcome::OutcomeExt;
|
|
|
|
fn make_services() -> EngineServices {
|
|
EngineServices::test_default()
|
|
}
|
|
|
|
fn test_store() -> Arc<Database> {
|
|
Arc::new(Database::new(
|
|
Arc::new(InMemory::new()),
|
|
"",
|
|
Duration::from_millis(1),
|
|
None,
|
|
))
|
|
}
|
|
|
|
async fn make_services_with_run_store() -> (
|
|
EngineServices,
|
|
RunDatabase,
|
|
crate::event::StoreProgressLogger,
|
|
) {
|
|
let store = test_store();
|
|
let run_store = store.create_run(&fixtures::RUN_1).await.unwrap();
|
|
seed_created(&run_store).await;
|
|
let mut services = EngineServices::test_default();
|
|
services.run = services
|
|
.run
|
|
.with_emitter(Arc::new(Emitter::new(fixtures::RUN_1)))
|
|
.with_run_store(run_store.clone().into());
|
|
let logger = crate::event::StoreProgressLogger::new(run_store.clone());
|
|
logger.register(services.run.emitter.as_ref());
|
|
(services, run_store, logger)
|
|
}
|
|
|
|
async fn seed_created(run_store: &RunDatabase) {
|
|
crate::event::append_event(
|
|
run_store,
|
|
&fixtures::RUN_1,
|
|
&crate::event::Event::RunCreated {
|
|
run_id: fixtures::RUN_1,
|
|
title: None,
|
|
settings: serde_json::to_value(fabro_types::WorkflowSettings::default())
|
|
.unwrap(),
|
|
graph: serde_json::to_value(fabro_types::Graph::new("test")).unwrap(),
|
|
workflow_source: None,
|
|
labels: std::collections::BTreeMap::default(),
|
|
source_directory: None,
|
|
workflow_slug: None,
|
|
automation: None,
|
|
provenance: test_support::test_run_provenance(),
|
|
manifest_blob: None,
|
|
git: None,
|
|
fork_source_ref: None,
|
|
retried_from: None,
|
|
parent_id: None,
|
|
web_url: None,
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn prompt_handler_simulate() {
|
|
let handler = PromptHandler::new(None);
|
|
let node = Node::new("classify");
|
|
let context = Context::new();
|
|
let graph = Graph::new("test");
|
|
let tmp = TempDir::new().unwrap();
|
|
|
|
let outcome = handler
|
|
.simulate(&node, &context, &graph, tmp.path(), &make_services())
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(outcome.status, crate::outcome::StageOutcome::Succeeded);
|
|
assert_eq!(outcome.notes.as_deref(), Some("[Simulated] classify"));
|
|
assert_eq!(
|
|
outcome
|
|
.context_updates
|
|
.get(crate::context::keys::LAST_STAGE),
|
|
Some(&serde_json::json!("classify"))
|
|
);
|
|
assert!(
|
|
outcome
|
|
.context_updates
|
|
.contains_key(crate::context::keys::LAST_RESPONSE)
|
|
);
|
|
assert_eq!(
|
|
outcome
|
|
.context_updates
|
|
.get(&crate::context::keys::response_key("classify")),
|
|
Some(&serde_json::json!(
|
|
"[Simulated] Response for stage: classify"
|
|
))
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn prompt_handler_dispatches_to_backend_one_shot() {
|
|
struct OneShotBackend;
|
|
|
|
#[async_trait]
|
|
impl CodergenBackend for OneShotBackend {
|
|
async fn run(&self, _request: CodergenRunRequest<'_>) -> Result<CodergenResult, Error> {
|
|
panic!("run() should not be called for prompt handler");
|
|
}
|
|
|
|
async fn one_shot(
|
|
&self,
|
|
_request: OneShotRequest<'_>,
|
|
) -> Result<CodergenResult, Error> {
|
|
Ok(CodergenResult::Text {
|
|
text: "one-shot response".to_string(),
|
|
usage: None,
|
|
files_touched: Vec::new(),
|
|
last_file_touched: None,
|
|
timing: StageTiming::default(),
|
|
})
|
|
}
|
|
|
|
fn effective_request_controls(
|
|
&self,
|
|
_node: &Node,
|
|
) -> Result<crate::handler::llm::api::EffectiveRequestControls, Error> {
|
|
Ok(crate::handler::llm::api::EffectiveRequestControls {
|
|
reasoning_effort: Some(ReasoningEffort::High),
|
|
speed: Some(Speed::Fast),
|
|
})
|
|
}
|
|
}
|
|
|
|
let handler = PromptHandler::new(Some(Box::new(OneShotBackend)));
|
|
let mut node = Node::new("classify");
|
|
node.attrs.insert(
|
|
"prompt".to_string(),
|
|
AttrValue::String("Classify this".to_string()),
|
|
);
|
|
let context = Context::new();
|
|
let graph = Graph::new("test");
|
|
let tmp = TempDir::new().unwrap();
|
|
|
|
let outcome = handler
|
|
.execute(&node, &context, &graph, tmp.path(), &make_services())
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(outcome.status, crate::outcome::StageOutcome::Succeeded);
|
|
|
|
assert_eq!(
|
|
outcome
|
|
.context_updates
|
|
.get(&crate::context::keys::response_key("classify")),
|
|
Some(&serde_json::json!("one-shot response"))
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn prompt_handler_copies_backend_timing_to_outcome() {
|
|
struct TimingBackend;
|
|
|
|
#[async_trait]
|
|
impl CodergenBackend for TimingBackend {
|
|
async fn run(&self, _request: CodergenRunRequest<'_>) -> Result<CodergenResult, Error> {
|
|
panic!("run() should not be called for prompt handler");
|
|
}
|
|
|
|
async fn one_shot(
|
|
&self,
|
|
_request: OneShotRequest<'_>,
|
|
) -> Result<CodergenResult, Error> {
|
|
Ok(CodergenResult::Text {
|
|
text: "one-shot response".to_string(),
|
|
usage: None,
|
|
files_touched: Vec::new(),
|
|
last_file_touched: None,
|
|
timing: StageTiming::new(0, 200, 300),
|
|
})
|
|
}
|
|
}
|
|
|
|
let handler = PromptHandler::new(Some(Box::new(TimingBackend)));
|
|
let node = Node::new("classify");
|
|
let context = Context::new();
|
|
let graph = Graph::new("test");
|
|
let tmp = TempDir::new().unwrap();
|
|
|
|
let outcome = handler
|
|
.execute(&node, &context, &graph, tmp.path(), &make_services())
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(outcome.timing, Some(StageTiming::new(0, 200, 300)));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn prompt_handler_custom_output_schema_updates_output_context_key() {
|
|
struct CustomOutputBackend;
|
|
|
|
#[async_trait]
|
|
impl CodergenBackend for CustomOutputBackend {
|
|
async fn run(&self, _request: CodergenRunRequest<'_>) -> Result<CodergenResult, Error> {
|
|
panic!("run() should not be called for prompt handler");
|
|
}
|
|
|
|
async fn one_shot(
|
|
&self,
|
|
_request: OneShotRequest<'_>,
|
|
) -> Result<CodergenResult, Error> {
|
|
Ok(CodergenResult::Text {
|
|
text: r#"{"passed": true}"#.to_string(),
|
|
usage: None,
|
|
files_touched: Vec::new(),
|
|
last_file_touched: None,
|
|
timing: StageTiming::default(),
|
|
})
|
|
}
|
|
}
|
|
|
|
let handler = PromptHandler::new(Some(Box::new(CustomOutputBackend)));
|
|
let mut node = Node::new("audit");
|
|
node.attrs.insert(
|
|
"output_schema".to_string(),
|
|
AttrValue::String(
|
|
r#"{"type":"object","required":["passed"],"properties":{"passed":{"type":"boolean"}}}"#
|
|
.to_string(),
|
|
),
|
|
);
|
|
let context = Context::new();
|
|
let graph = Graph::new("test");
|
|
let tmp = TempDir::new().unwrap();
|
|
|
|
let outcome = handler
|
|
.execute(&node, &context, &graph, tmp.path(), &make_services())
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
outcome.context_updates.get("output.audit"),
|
|
Some(&serde_json::json!({"passed": true})),
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn prompt_handler_routing_output_schema_requires_valid_routing_json() {
|
|
struct BadRoutingBackend;
|
|
|
|
#[async_trait]
|
|
impl CodergenBackend for BadRoutingBackend {
|
|
async fn run(&self, _request: CodergenRunRequest<'_>) -> Result<CodergenResult, Error> {
|
|
panic!("run() should not be called for prompt handler");
|
|
}
|
|
|
|
async fn one_shot(
|
|
&self,
|
|
_request: OneShotRequest<'_>,
|
|
) -> Result<CodergenResult, Error> {
|
|
Ok(CodergenResult::Text {
|
|
text: r#"{"outcome": 123}"#.to_string(),
|
|
usage: None,
|
|
files_touched: Vec::new(),
|
|
last_file_touched: None,
|
|
timing: StageTiming::default(),
|
|
})
|
|
}
|
|
}
|
|
|
|
let handler = PromptHandler::new(Some(Box::new(BadRoutingBackend)));
|
|
let mut node = Node::new("route");
|
|
node.attrs.insert(
|
|
"output_schema".to_string(),
|
|
AttrValue::String("routing".to_string()),
|
|
);
|
|
node.attrs
|
|
.insert("output_retries".to_string(), AttrValue::Integer(0));
|
|
let context = Context::new();
|
|
let graph = Graph::new("test");
|
|
let tmp = TempDir::new().unwrap();
|
|
|
|
let outcome = handler
|
|
.execute(&node, &context, &graph, tmp.path(), &make_services())
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(outcome.status, crate::outcome::StageOutcome::Failed {
|
|
retry_requested: false,
|
|
});
|
|
assert_eq!(
|
|
outcome.failure_reason(),
|
|
Some("output schema validation failed after 0 repair attempt(s)")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn prompt_handler_projects_provider_used_from_prompt_events() {
|
|
struct ProviderOneShotBackend;
|
|
|
|
#[async_trait]
|
|
impl CodergenBackend for ProviderOneShotBackend {
|
|
async fn run(&self, _request: CodergenRunRequest<'_>) -> Result<CodergenResult, Error> {
|
|
panic!("run() should not be called for prompt handler");
|
|
}
|
|
|
|
async fn one_shot(
|
|
&self,
|
|
_request: OneShotRequest<'_>,
|
|
) -> Result<CodergenResult, Error> {
|
|
Ok(CodergenResult::Text {
|
|
text: "one-shot response".to_string(),
|
|
usage: None,
|
|
files_touched: Vec::new(),
|
|
last_file_touched: None,
|
|
timing: StageTiming::default(),
|
|
})
|
|
}
|
|
|
|
fn effective_request_controls(
|
|
&self,
|
|
_node: &Node,
|
|
) -> Result<crate::handler::llm::api::EffectiveRequestControls, Error> {
|
|
Ok(crate::handler::llm::api::EffectiveRequestControls {
|
|
reasoning_effort: Some(ReasoningEffort::High),
|
|
speed: Some(Speed::Fast),
|
|
})
|
|
}
|
|
}
|
|
|
|
let handler = PromptHandler::new(Some(Box::new(ProviderOneShotBackend)));
|
|
let mut node = Node::new("classify");
|
|
node.attrs.insert(
|
|
"prompt".to_string(),
|
|
AttrValue::String("Classify this".to_string()),
|
|
);
|
|
let context = Context::new();
|
|
let graph = Graph::new("test");
|
|
let tmp = TempDir::new().unwrap();
|
|
let (services, run_store, logger) = make_services_with_run_store().await;
|
|
|
|
handler
|
|
.execute(&node, &context, &graph, tmp.path(), &services)
|
|
.await
|
|
.unwrap();
|
|
logger.flush().await;
|
|
|
|
let state = run_store.state().await.unwrap();
|
|
let node_state = state.stage(&StageId::new("classify", 1)).unwrap();
|
|
let provider_used = node_state.provider_used.as_ref().unwrap();
|
|
assert_eq!(provider_used.mode, StageModelUsage::MODE_PROMPT);
|
|
assert_eq!(provider_used.reasoning_effort, Some(ReasoningEffort::High));
|
|
assert_eq!(provider_used.speed, Some(Speed::Fast));
|
|
}
|
|
|
|
struct OneShotCapturingBackend {
|
|
captured_prompt: Arc<std::sync::Mutex<Option<String>>>,
|
|
captured_system_prompt: Arc<std::sync::Mutex<Option<Option<String>>>>,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl CodergenBackend for OneShotCapturingBackend {
|
|
async fn run(&self, _request: CodergenRunRequest<'_>) -> Result<CodergenResult, Error> {
|
|
panic!("run() should not be called for prompt handler");
|
|
}
|
|
|
|
async fn one_shot(&self, request: OneShotRequest<'_>) -> Result<CodergenResult, Error> {
|
|
*self.captured_prompt.lock().unwrap() = Some(request.prompt.to_string());
|
|
*self.captured_system_prompt.lock().unwrap() =
|
|
Some(request.system_prompt.map(String::from));
|
|
Ok(CodergenResult::Text {
|
|
text: "classified".to_string(),
|
|
usage: None,
|
|
files_touched: Vec::new(),
|
|
last_file_touched: None,
|
|
timing: StageTiming::default(),
|
|
})
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn prompt_handler_prepends_preamble() {
|
|
use std::sync::Mutex;
|
|
|
|
let captured = Arc::new(Mutex::new(None));
|
|
let backend = OneShotCapturingBackend {
|
|
captured_prompt: captured.clone(),
|
|
captured_system_prompt: Arc::new(Mutex::new(None)),
|
|
};
|
|
let handler = PromptHandler::new(Some(Box::new(backend)));
|
|
|
|
let mut node = Node::new("classify");
|
|
node.attrs.insert(
|
|
"prompt".to_string(),
|
|
AttrValue::String("Classify this".to_string()),
|
|
);
|
|
let context = Context::new();
|
|
context.set(
|
|
keys::CURRENT_PREAMBLE,
|
|
serde_json::json!("Prior output here"),
|
|
);
|
|
let graph = Graph::new("test");
|
|
let tmp = TempDir::new().unwrap();
|
|
|
|
handler
|
|
.execute(&node, &context, &graph, tmp.path(), &make_services())
|
|
.await
|
|
.unwrap();
|
|
|
|
let prompt = captured.lock().unwrap().clone().unwrap();
|
|
assert!(
|
|
prompt.starts_with("Prior output here"),
|
|
"one_shot prompt should start with preamble, got: {prompt}"
|
|
);
|
|
assert!(prompt.ends_with("Classify this"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn prompt_handler_passes_system_prompt_when_project_memory_enabled() {
|
|
use std::sync::Mutex;
|
|
|
|
let captured_sys = Arc::new(Mutex::new(None));
|
|
let backend = OneShotCapturingBackend {
|
|
captured_prompt: Arc::new(Mutex::new(None)),
|
|
captured_system_prompt: captured_sys.clone(),
|
|
};
|
|
let handler = PromptHandler::new(Some(Box::new(backend)));
|
|
|
|
// project_memory defaults to true; sandbox working_directory points to cwd
|
|
// which likely has no AGENTS.md/CLAUDE.md, so system_prompt should be None
|
|
let mut node = Node::new("classify");
|
|
node.attrs.insert(
|
|
"prompt".to_string(),
|
|
AttrValue::String("Classify this".to_string()),
|
|
);
|
|
let context = Context::new();
|
|
let graph = Graph::new("test");
|
|
let tmp = TempDir::new().unwrap();
|
|
|
|
handler
|
|
.execute(&node, &context, &graph, tmp.path(), &make_services())
|
|
.await
|
|
.unwrap();
|
|
|
|
// With project_memory=true (default), one_shot is called (system_prompt
|
|
// captured)
|
|
let sys = captured_sys.lock().unwrap().clone();
|
|
assert!(sys.is_some(), "one_shot should have been called");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn prompt_handler_project_memory_uses_model_agent_profile_override() {
|
|
use std::sync::Mutex;
|
|
|
|
let captured_sys = Arc::new(Mutex::new(None));
|
|
let backend = OneShotCapturingBackend {
|
|
captured_prompt: Arc::new(Mutex::new(None)),
|
|
captured_system_prompt: captured_sys.clone(),
|
|
};
|
|
let handler = PromptHandler::new(Some(Box::new(backend)));
|
|
let workspace = TempDir::new().unwrap();
|
|
tokio::fs::write(workspace.path().join("CLAUDE.md"), "anthropic memory")
|
|
.await
|
|
.unwrap();
|
|
let overrides: fabro_model::catalog::LlmCatalogSettings = toml::from_str(
|
|
r#"
|
|
[providers.acme]
|
|
adapter = "openai_compatible"
|
|
agent_profile = "openai"
|
|
base_url = "https://api.acme.test/v1"
|
|
|
|
[models.acme-claude]
|
|
provider = "acme"
|
|
display_name = "Acme Claude"
|
|
family = "claude"
|
|
default = true
|
|
agent_profile = "anthropic"
|
|
aliases = ["ac"]
|
|
|
|
[models.acme-claude.limits]
|
|
context_window = 1000
|
|
|
|
[models.acme-claude.features]
|
|
tools = true
|
|
vision = false
|
|
reasoning = false
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
let catalog =
|
|
Arc::new(fabro_model::Catalog::from_builtin_with_overrides(&overrides).unwrap());
|
|
let mut services = make_services();
|
|
services.run = services
|
|
.run
|
|
.with_sandbox(Arc::new(fabro_agent::LocalSandbox::new(
|
|
workspace.path().to_path_buf(),
|
|
)))
|
|
.with_catalog_context(
|
|
Arc::clone(&catalog),
|
|
fabro_model::ProviderId::new("acme"),
|
|
"acme-claude".to_string(),
|
|
);
|
|
|
|
let mut node = Node::new("classify");
|
|
node.attrs.insert(
|
|
"prompt".to_string(),
|
|
AttrValue::String("Classify this".to_string()),
|
|
);
|
|
node.attrs
|
|
.insert("model".to_string(), AttrValue::String("ac".to_string()));
|
|
let context = Context::new();
|
|
let graph = Graph::new("test");
|
|
|
|
handler
|
|
.execute(&node, &context, &graph, workspace.path(), &services)
|
|
.await
|
|
.unwrap();
|
|
|
|
let sys = captured_sys.lock().unwrap().clone();
|
|
assert!(
|
|
sys.flatten()
|
|
.is_some_and(|system_prompt| system_prompt.contains("anthropic memory")),
|
|
"project memory should use model-level Anthropic profile and read CLAUDE.md"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn prompt_handler_project_memory_uses_default_model_profile_for_provider_attr() {
|
|
use std::sync::Mutex;
|
|
|
|
let captured_sys = Arc::new(Mutex::new(None));
|
|
let backend = OneShotCapturingBackend {
|
|
captured_prompt: Arc::new(Mutex::new(None)),
|
|
captured_system_prompt: captured_sys.clone(),
|
|
};
|
|
let handler = PromptHandler::new(Some(Box::new(backend)));
|
|
let workspace = TempDir::new().unwrap();
|
|
tokio::fs::write(workspace.path().join("CLAUDE.md"), "anthropic memory")
|
|
.await
|
|
.unwrap();
|
|
let overrides: fabro_model::catalog::LlmCatalogSettings = toml::from_str(
|
|
r#"
|
|
[providers.acme]
|
|
adapter = "openai_compatible"
|
|
agent_profile = "openai"
|
|
base_url = "https://api.acme.test/v1"
|
|
|
|
[models.acme-claude]
|
|
provider = "acme"
|
|
display_name = "Acme Claude"
|
|
family = "claude"
|
|
default = true
|
|
agent_profile = "anthropic"
|
|
|
|
[models.acme-claude.limits]
|
|
context_window = 1000
|
|
|
|
[models.acme-claude.features]
|
|
tools = true
|
|
vision = false
|
|
reasoning = false
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
let catalog =
|
|
Arc::new(fabro_model::Catalog::from_builtin_with_overrides(&overrides).unwrap());
|
|
let mut services = make_services();
|
|
services.run = services
|
|
.run
|
|
.with_sandbox(Arc::new(fabro_agent::LocalSandbox::new(
|
|
workspace.path().to_path_buf(),
|
|
)))
|
|
.with_catalog_context(
|
|
Arc::clone(&catalog),
|
|
fabro_model::ProviderId::new("acme"),
|
|
"acme-claude".to_string(),
|
|
);
|
|
|
|
let mut node = Node::new("classify");
|
|
node.attrs.insert(
|
|
"prompt".to_string(),
|
|
AttrValue::String("Classify this".to_string()),
|
|
);
|
|
node.attrs.insert(
|
|
"provider".to_string(),
|
|
AttrValue::String("acme".to_string()),
|
|
);
|
|
let context = Context::new();
|
|
let graph = Graph::new("test");
|
|
|
|
handler
|
|
.execute(&node, &context, &graph, workspace.path(), &services)
|
|
.await
|
|
.unwrap();
|
|
|
|
let sys = captured_sys.lock().unwrap().clone();
|
|
assert!(
|
|
sys.flatten()
|
|
.is_some_and(|system_prompt| system_prompt.contains("anthropic memory")),
|
|
"project memory should use the default model's Anthropic profile when only the matching provider is set"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn prompt_handler_passes_none_system_prompt_when_project_memory_false() {
|
|
use std::sync::Mutex;
|
|
|
|
let captured_sys = Arc::new(Mutex::new(None));
|
|
let backend = OneShotCapturingBackend {
|
|
captured_prompt: Arc::new(Mutex::new(None)),
|
|
captured_system_prompt: captured_sys.clone(),
|
|
};
|
|
let handler = PromptHandler::new(Some(Box::new(backend)));
|
|
|
|
let mut node = Node::new("classify");
|
|
node.attrs.insert(
|
|
"prompt".to_string(),
|
|
AttrValue::String("Classify this".to_string()),
|
|
);
|
|
node.attrs
|
|
.insert("project_memory".to_string(), AttrValue::Boolean(false));
|
|
let context = Context::new();
|
|
let graph = Graph::new("test");
|
|
let tmp = TempDir::new().unwrap();
|
|
|
|
handler
|
|
.execute(&node, &context, &graph, tmp.path(), &make_services())
|
|
.await
|
|
.unwrap();
|
|
|
|
let sys = captured_sys.lock().unwrap().clone();
|
|
assert_eq!(
|
|
sys,
|
|
Some(None),
|
|
"system_prompt should be None when project_memory=false"
|
|
);
|
|
}
|
|
}
|