use crate::codex::{is_dashboard_component, is_persisted_answer_component};
use crate::error::DispatchError;
use crate::interactive::{
native_analysis_prompt_fragment, native_analysis_terminal_presentation_instructions,
native_answer_persistence_instructions, native_dashboard_save_instructions,
prepare_interactive_output_with_host, NativeEntryKind, NativeMcpDescriptor, NativePurpose,
NativeSessionScope, PiLaunch, NATIVE_DASHBOARD_SAVE_TOOL, NATIVE_MCP_SERVER_NAME,
NATIVE_PERSIST_ANSWER_TOOL, NATIVE_QUERY_TOOL,
};
use crate::ir::{
reject_unsupported_component_composition, validate_ir_version, OutcomeKind, RealizationKind,
TriggerKind, WarbleIr,
};
use crate::resolve::resolve_capabilities;
use crate::targets::TargetId;
use serde_json::json;
use std::fs;
use std::path::{Path, PathBuf};
pub const PI_SYSTEM_PROMPT_PATH: &str = ".warble/pi/SYSTEM.md";
pub const PI_AGENT_DIR_PATH: &str = ".warble/pi/agent";
pub const PI_SETTINGS_PATH: &str = ".warble/pi/agent/settings.json";
pub const PI_MCP_CONFIG_PATH: &str = ".warble/pi/agent/mcp.json";
pub const PI_MINIMUM_VERSION: &str = "1.0.4";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PiModel {
pub provider: String,
pub model: String,
}
impl PiModel {
pub fn parse(value: &str) -> Result<Self, DispatchError> {
let invalid = || {
DispatchError(format!(
"--pi-model must be `<provider>/<model-id>` with a non-empty provider id of \
letters, digits, `.`, `_` or `-` and a non-empty model id without whitespace \
or control characters, neither starting with `-`, got {value:?}"
))
};
if value.len() > 256 {
return Err(invalid());
}
let (provider, model) = value.split_once('/').ok_or_else(invalid)?;
if provider.is_empty()
|| model.is_empty()
|| provider.starts_with('-')
|| model.starts_with('-')
|| !provider
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
|| model.chars().any(|c| c.is_whitespace() || c.is_control())
{
return Err(invalid());
}
Ok(PiModel {
provider: provider.to_string(),
model: model.to_string(),
})
}
}
pub fn emit_pi_interactive(
ir: &WarbleIr,
out_dir: &Path,
purpose: Option<NativePurpose>,
native_scope: Option<NativeSessionScope>,
native_mcp: Option<NativeMcpDescriptor>,
model: &PiModel,
) -> Result<(), DispatchError> {
emit_pi_interactive_with_host(ir, out_dir, purpose, native_scope, native_mcp, None, model)
}
pub fn emit_pi_interactive_with_host(
ir: &WarbleIr,
out_dir: &Path,
purpose: Option<NativePurpose>,
native_scope: Option<NativeSessionScope>,
native_mcp: Option<NativeMcpDescriptor>,
native_host: Option<crate::native_host::NativeHost>,
model: &PiModel,
) -> Result<(), DispatchError> {
validate_ir_version(ir)?;
if ir.context_binding.absent {
return Err(DispatchError::new(
"context-free IR is not supported by pi:interactive",
));
}
let target = TargetId::PiInteractive.as_str();
let purpose = match purpose {
Some(NativePurpose::Analysis) => NativePurpose::Analysis,
Some(other) => {
return Err(DispatchError(format!(
"{target} realizes only --purpose analysis; purpose '{}' is not materializable on this target (wall-hit)",
other.as_str()
)))
}
None => {
return Err(DispatchError(format!(
"{target} requires --purpose analysis with --native-scope and --native-mcp; it has no purpose-less launch contract"
)))
}
};
let scope = native_scope.ok_or_else(|| {
DispatchError(
"native Sessions purpose requires a server-derived native scope descriptor".to_string(),
)
})?;
let descriptor = native_mcp.ok_or_else(|| {
DispatchError(format!(
"{target} requires --native-mcp: a pi session has no tools other than the host-owned MCP server"
))
})?;
if let Some(host) = &native_host {
host.validate(ir, target, Some(purpose), Some(&scope), true)?;
}
let mut entry_ir = WarbleIr {
components: ir
.components
.iter()
.filter(|node| node.entrypoint)
.cloned()
.collect(),
..ir.clone()
};
purpose.validate_profile(&entry_ir, &scope.entry)?;
let verb = match (scope.entry.kind, scope.entry.pinned_verb()) {
(NativeEntryKind::Agent, Some(verb)) => verb.to_string(),
_ => {
return Err(DispatchError(format!(
"native pi sessions pin one component through the scope entry verb and do not support scope entry ({target})"
)))
}
};
entry_ir.components.retain(|node| node.id == verb);
let ir = &entry_ir;
if native_host.is_none() {
reject_unsupported_component_composition(ir, target)?;
}
let node = ir.components.first().ok_or_else(|| {
DispatchError(format!(
"{target}: the pinned entry '{verb}' is not an entry component of this profile"
))
})?;
if !matches!(node.trigger.kind, TriggerKind::OneShot)
|| !matches!(node.realization_kind, RealizationKind::Skill)
|| !matches!(node.effect.outcome.kind, OutcomeKind::None)
{
return Err(DispatchError(format!(
"{target} cannot materialize component '{}' shape: only a one-shot skill with no outcome is realized on this target (wall-hit)",
node.id
)));
}
if native_host
.as_ref()
.is_none_or(|host| host.tool(&node.id).is_none())
{
resolve_capabilities(node, target, &TargetId::PiInteractive.profile())?;
}
let include_persist_answer = is_persisted_answer_component(node);
let include_dashboard_save = is_dashboard_component(node) || include_persist_answer;
let mut tools: Vec<String> = match &native_host {
Some(host) => {
let mut names = host
.tool_names()
.into_iter()
.map(str::to_string)
.collect::<Vec<_>>();
names.sort();
names
}
None => {
let mut names = Vec::new();
if include_persist_answer {
names.push(NATIVE_PERSIST_ANSWER_TOOL.to_string());
names.push(NATIVE_QUERY_TOOL.to_string());
}
if include_dashboard_save {
names.push(NATIVE_DASHBOARD_SAVE_TOOL.to_string());
}
names
}
};
tools.dedup();
if tools.is_empty() {
return Err(DispatchError(format!(
"{target} cannot materialize component '{}': no host MCP tool serves its shape, so the session would have no tools at all (wall-hit)",
node.id
)));
}
let signature = node.id.clone();
let system_prompt_relative = PathBuf::from(PI_SYSTEM_PROMPT_PATH);
let settings_relative = PathBuf::from(PI_SETTINGS_PATH);
let mcp_relative = PathBuf::from(PI_MCP_CONFIG_PATH);
let run_relative = PathBuf::from("RUN.md");
let mut owned_paths = vec![
system_prompt_relative.clone(),
settings_relative.clone(),
mcp_relative.clone(),
run_relative.clone(),
];
if native_host.is_some() {
owned_paths.push(PathBuf::from(".warble/component-plans.json"));
}
let launch = PiLaunch {
provider: model.provider.clone(),
model: model.model.clone(),
tools: tools
.iter()
.map(|tool| format!("mcp__{NATIVE_MCP_SERVER_NAME}__{tool}"))
.collect(),
system_prompt: system_prompt_relative.clone(),
agent_dir: PathBuf::from(PI_AGENT_DIR_PATH),
};
let output = prepare_interactive_output_with_host(
out_dir,
target,
"pi",
&signature,
&ir.profile,
&owned_paths,
Some(purpose),
Some(scope.clone()),
Some(descriptor.clone()),
native_host.clone(),
Some(launch),
)?;
let system_prompt = match &native_host {
Some(host) => format!(
"{}\n\n# GenBI analysis\n\n{}\n",
output.marker(),
host.instructions(node)
),
None => build_system_prompt(
node,
output.marker(),
include_persist_answer,
include_dashboard_save,
),
};
let run = build_run(output.marker(), &node.id);
let agent_dir = output.root.join(PI_AGENT_DIR_PATH);
fs::create_dir_all(&agent_dir)
.map_err(|e| DispatchError(format!("create pi agent dir: {e}")))?;
fs::write(output.root.join(&system_prompt_relative), system_prompt)
.map_err(|e| DispatchError(format!("write pi system prompt: {e}")))?;
fs::write(output.root.join(&settings_relative), pi_settings())
.map_err(|e| DispatchError(format!("write pi settings: {e}")))?;
fs::write(
output.root.join(&mcp_relative),
descriptor.pi_discovery_config()?,
)
.map_err(|e| DispatchError(format!("write pi MCP discovery config: {e}")))?;
fs::write(output.root.join(&run_relative), run)
.map_err(|e| DispatchError(format!("write RUN.md: {e}")))?;
output.write_ownership()?;
output.write_launch_spec()
}
fn pi_settings() -> String {
let settings = json!({
"defaultProjectTrust": "never",
"defaultTools": [],
"enableAnalytics": false,
"enableInstallTelemetry": false,
"quietStartup": true,
"retry": { "enabled": false },
});
format!(
"{}\n",
serde_json::to_string_pretty(&settings).expect("settings serialize")
)
}
fn build_system_prompt(
node: &crate::ir::ComponentNode,
marker: &str,
include_persist_answer: bool,
include_dashboard_save: bool,
) -> String {
let fragment = native_analysis_prompt_fragment(&node.prompt_fragment);
let body = match &node.brief {
Some(brief) => format!("{brief}\n\n{fragment}"),
None => fragment,
};
let mut prompt = format!(
"{marker}\n\n# GenBI analysis\n\n\
Operate only within the server-bound project scope. Do not change cwd or follow a \
caller-supplied project path.\n\n{body}\n\n\
You have no shell, file, or network tools. The only tools available are the \
`{NATIVE_MCP_SERVER_NAME}` MCP tools the host advertised. Do not read credentials or \
expose raw material, and do not report a result you did not obtain through those tools.\n"
);
if include_persist_answer {
prompt.push('\n');
prompt.push_str(native_answer_persistence_instructions());
}
prompt.push('\n');
prompt.push_str(native_analysis_terminal_presentation_instructions());
if include_dashboard_save {
prompt.push('\n');
prompt.push_str(native_dashboard_save_instructions());
}
prompt
}
fn build_run(marker: &str, verb: &str) -> String {
format!(
"{marker}\n# Native pi analysis session\n\n\
Read `.warble/interactive-launch.json`, then start `pi` in its canonical cwd with exactly \
its `argv` (an RPC session) or exactly `pi.json_argv` (one JSON-mode turn), with \
`PI_CODING_AGENT_DIR` set to `pi.agent_dir` and the MCP credential supplied in the \
`mcp.credential_env_var` environment variable. Over RPC the caller sends `pi.first_prompt` \
as the first `prompt` command. pi's built-in tools are disabled; the session is pinned to \
the `{verb}` component and may use only the `pi.tools` MCP tools. The caller owns the \
process, transport, transcript, and session lifecycle, and judges success from the event \
stream, never from the exit code.\n"
)
}