1use crate::codex::{is_dashboard_component, is_persisted_answer_component};
11use crate::error::DispatchError;
12use crate::interactive::{
13 native_analysis_prompt_fragment, native_analysis_terminal_presentation_instructions,
14 native_answer_persistence_instructions, native_dashboard_save_instructions,
15 prepare_interactive_output_with_host, NativeEntryKind, NativeMcpDescriptor, NativePurpose,
16 NativeSessionScope, PiLaunch, NATIVE_DASHBOARD_SAVE_TOOL, NATIVE_MCP_SERVER_NAME,
17 NATIVE_PERSIST_ANSWER_TOOL, NATIVE_QUERY_TOOL,
18};
19use crate::ir::{
20 reject_unsupported_component_composition, validate_ir_version, OutcomeKind, RealizationKind,
21 TriggerKind, WarbleIr,
22};
23use crate::resolve::resolve_capabilities;
24use crate::targets::TargetId;
25use serde_json::json;
26use std::fs;
27use std::path::{Path, PathBuf};
28
29pub const PI_SYSTEM_PROMPT_PATH: &str = ".warble/pi/SYSTEM.md";
32pub const PI_AGENT_DIR_PATH: &str = ".warble/pi/agent";
35pub const PI_SETTINGS_PATH: &str = ".warble/pi/agent/settings.json";
36pub const PI_MCP_CONFIG_PATH: &str = ".warble/pi/agent/mcp.json";
37pub const PI_MINIMUM_VERSION: &str = "1.0.4";
40
41#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct PiModel {
47 pub provider: String,
48 pub model: String,
49}
50
51impl PiModel {
52 pub fn parse(value: &str) -> Result<Self, DispatchError> {
55 let invalid = || {
56 DispatchError(format!(
57 "--pi-model must be `<provider>/<model-id>` with a non-empty provider id of \
58 letters, digits, `.`, `_` or `-` and a non-empty model id without whitespace \
59 or control characters, neither starting with `-`, got {value:?}"
60 ))
61 };
62 if value.len() > 256 {
63 return Err(invalid());
64 }
65 let (provider, model) = value.split_once('/').ok_or_else(invalid)?;
66 if provider.is_empty()
69 || model.is_empty()
70 || provider.starts_with('-')
71 || model.starts_with('-')
72 || !provider
73 .chars()
74 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
75 || model.chars().any(|c| c.is_whitespace() || c.is_control())
76 {
77 return Err(invalid());
78 }
79 Ok(PiModel {
80 provider: provider.to_string(),
81 model: model.to_string(),
82 })
83 }
84}
85
86pub fn emit_pi_interactive(
87 ir: &WarbleIr,
88 out_dir: &Path,
89 purpose: Option<NativePurpose>,
90 native_scope: Option<NativeSessionScope>,
91 native_mcp: Option<NativeMcpDescriptor>,
92 model: &PiModel,
93) -> Result<(), DispatchError> {
94 emit_pi_interactive_with_host(ir, out_dir, purpose, native_scope, native_mcp, None, model)
95}
96
97pub fn emit_pi_interactive_with_host(
99 ir: &WarbleIr,
100 out_dir: &Path,
101 purpose: Option<NativePurpose>,
102 native_scope: Option<NativeSessionScope>,
103 native_mcp: Option<NativeMcpDescriptor>,
104 native_host: Option<crate::native_host::NativeHost>,
105 model: &PiModel,
106) -> Result<(), DispatchError> {
107 validate_ir_version(ir)?;
108 if ir.context_binding.absent {
109 return Err(DispatchError::new(
110 "context-free IR is not supported by pi:interactive",
111 ));
112 }
113 let target = TargetId::PiInteractive.as_str();
114 let purpose = match purpose {
118 Some(NativePurpose::Analysis) => NativePurpose::Analysis,
119 Some(other) => {
120 return Err(DispatchError(format!(
121 "{target} realizes only --purpose analysis; purpose '{}' is not materializable on this target (wall-hit)",
122 other.as_str()
123 )))
124 }
125 None => {
126 return Err(DispatchError(format!(
127 "{target} requires --purpose analysis with --native-scope and --native-mcp; it has no purpose-less launch contract"
128 )))
129 }
130 };
131 let scope = native_scope.ok_or_else(|| {
132 DispatchError(
133 "native Sessions purpose requires a server-derived native scope descriptor".to_string(),
134 )
135 })?;
136 let descriptor = native_mcp.ok_or_else(|| {
137 DispatchError(format!(
138 "{target} requires --native-mcp: a pi session has no tools other than the host-owned MCP server"
139 ))
140 })?;
141 if let Some(host) = &native_host {
142 host.validate(ir, target, Some(purpose), Some(&scope), true)?;
143 }
144 let mut entry_ir = WarbleIr {
145 components: ir
146 .components
147 .iter()
148 .filter(|node| node.entrypoint)
149 .cloned()
150 .collect(),
151 ..ir.clone()
152 };
153 purpose.validate_profile(&entry_ir, &scope.entry)?;
155 let verb = match (scope.entry.kind, scope.entry.pinned_verb()) {
158 (NativeEntryKind::Agent, Some(verb)) => verb.to_string(),
159 _ => {
160 return Err(DispatchError(format!(
161 "native pi sessions pin one component through the scope entry verb and do not support scope entry ({target})"
162 )))
163 }
164 };
165 entry_ir.components.retain(|node| node.id == verb);
166 let ir = &entry_ir;
167 if native_host.is_none() {
168 reject_unsupported_component_composition(ir, target)?;
169 }
170 let node = ir.components.first().ok_or_else(|| {
171 DispatchError(format!(
172 "{target}: the pinned entry '{verb}' is not an entry component of this profile"
173 ))
174 })?;
175 if !matches!(node.trigger.kind, TriggerKind::OneShot)
176 || !matches!(node.realization_kind, RealizationKind::Skill)
177 || !matches!(node.effect.outcome.kind, OutcomeKind::None)
178 {
179 return Err(DispatchError(format!(
180 "{target} cannot materialize component '{}' shape: only a one-shot skill with no outcome is realized on this target (wall-hit)",
181 node.id
182 )));
183 }
184 if native_host
185 .as_ref()
186 .is_none_or(|host| host.tool(&node.id).is_none())
187 {
188 resolve_capabilities(node, target, &TargetId::PiInteractive.profile())?;
189 }
190 let include_persist_answer = is_persisted_answer_component(node);
195 let include_dashboard_save = is_dashboard_component(node) || include_persist_answer;
196 let mut tools: Vec<String> = match &native_host {
197 Some(host) => {
198 let mut names = host
199 .tool_names()
200 .into_iter()
201 .map(str::to_string)
202 .collect::<Vec<_>>();
203 names.sort();
204 names
205 }
206 None => {
207 let mut names = Vec::new();
208 if include_persist_answer {
209 names.push(NATIVE_PERSIST_ANSWER_TOOL.to_string());
210 names.push(NATIVE_QUERY_TOOL.to_string());
211 }
212 if include_dashboard_save {
213 names.push(NATIVE_DASHBOARD_SAVE_TOOL.to_string());
214 }
215 names
216 }
217 };
218 tools.dedup();
219 if tools.is_empty() {
220 return Err(DispatchError(format!(
221 "{target} cannot materialize component '{}': no host MCP tool serves its shape, so the session would have no tools at all (wall-hit)",
222 node.id
223 )));
224 }
225 let signature = node.id.clone();
226 let system_prompt_relative = PathBuf::from(PI_SYSTEM_PROMPT_PATH);
227 let settings_relative = PathBuf::from(PI_SETTINGS_PATH);
228 let mcp_relative = PathBuf::from(PI_MCP_CONFIG_PATH);
229 let run_relative = PathBuf::from("RUN.md");
230 let mut owned_paths = vec![
231 system_prompt_relative.clone(),
232 settings_relative.clone(),
233 mcp_relative.clone(),
234 run_relative.clone(),
235 ];
236 if native_host.is_some() {
237 owned_paths.push(PathBuf::from(".warble/component-plans.json"));
238 }
239 let launch = PiLaunch {
240 provider: model.provider.clone(),
241 model: model.model.clone(),
242 tools: tools
243 .iter()
244 .map(|tool| format!("mcp__{NATIVE_MCP_SERVER_NAME}__{tool}"))
245 .collect(),
246 system_prompt: system_prompt_relative.clone(),
247 agent_dir: PathBuf::from(PI_AGENT_DIR_PATH),
248 };
249 let output = prepare_interactive_output_with_host(
250 out_dir,
251 target,
252 "pi",
253 &signature,
254 &ir.profile,
255 &owned_paths,
256 Some(purpose),
257 Some(scope.clone()),
258 Some(descriptor.clone()),
259 native_host.clone(),
260 Some(launch),
261 )?;
262
263 let system_prompt = match &native_host {
264 Some(host) => format!(
265 "{}\n\n# GenBI analysis\n\n{}\n",
266 output.marker(),
267 host.instructions(node)
268 ),
269 None => build_system_prompt(
270 node,
271 output.marker(),
272 include_persist_answer,
273 include_dashboard_save,
274 ),
275 };
276 let run = build_run(output.marker(), &node.id);
277 let agent_dir = output.root.join(PI_AGENT_DIR_PATH);
278 fs::create_dir_all(&agent_dir)
279 .map_err(|e| DispatchError(format!("create pi agent dir: {e}")))?;
280 fs::write(output.root.join(&system_prompt_relative), system_prompt)
281 .map_err(|e| DispatchError(format!("write pi system prompt: {e}")))?;
282 fs::write(output.root.join(&settings_relative), pi_settings())
283 .map_err(|e| DispatchError(format!("write pi settings: {e}")))?;
284 fs::write(
285 output.root.join(&mcp_relative),
286 descriptor.pi_discovery_config()?,
287 )
288 .map_err(|e| DispatchError(format!("write pi MCP discovery config: {e}")))?;
289 fs::write(output.root.join(&run_relative), run)
290 .map_err(|e| DispatchError(format!("write RUN.md: {e}")))?;
291 output.write_ownership()?;
292 output.write_launch_spec()
293}
294
295fn pi_settings() -> String {
300 let settings = json!({
301 "defaultProjectTrust": "never",
302 "defaultTools": [],
303 "enableAnalytics": false,
304 "enableInstallTelemetry": false,
305 "quietStartup": true,
306 "retry": { "enabled": false },
307 });
308 format!(
309 "{}\n",
310 serde_json::to_string_pretty(&settings).expect("settings serialize")
311 )
312}
313
314fn build_system_prompt(
315 node: &crate::ir::ComponentNode,
316 marker: &str,
317 include_persist_answer: bool,
318 include_dashboard_save: bool,
319) -> String {
320 let fragment = native_analysis_prompt_fragment(&node.prompt_fragment);
321 let body = match &node.brief {
322 Some(brief) => format!("{brief}\n\n{fragment}"),
323 None => fragment,
324 };
325 let mut prompt = format!(
326 "{marker}\n\n# GenBI analysis\n\n\
327 Operate only within the server-bound project scope. Do not change cwd or follow a \
328 caller-supplied project path.\n\n{body}\n\n\
329 You have no shell, file, or network tools. The only tools available are the \
330 `{NATIVE_MCP_SERVER_NAME}` MCP tools the host advertised. Do not read credentials or \
331 expose raw material, and do not report a result you did not obtain through those tools.\n"
332 );
333 if include_persist_answer {
334 prompt.push('\n');
335 prompt.push_str(native_answer_persistence_instructions());
336 }
337 prompt.push('\n');
338 prompt.push_str(native_analysis_terminal_presentation_instructions());
339 if include_dashboard_save {
340 prompt.push('\n');
341 prompt.push_str(native_dashboard_save_instructions());
342 }
343 prompt
344}
345
346fn build_run(marker: &str, verb: &str) -> String {
347 format!(
348 "{marker}\n# Native pi analysis session\n\n\
349 Read `.warble/interactive-launch.json`, then start `pi` in its canonical cwd with exactly \
350 its `argv` (an RPC session) or exactly `pi.json_argv` (one JSON-mode turn), with \
351 `PI_CODING_AGENT_DIR` set to `pi.agent_dir` and the MCP credential supplied in the \
352 `mcp.credential_env_var` environment variable. Over RPC the caller sends `pi.first_prompt` \
353 as the first `prompt` command. pi's built-in tools are disabled; the session is pinned to \
354 the `{verb}` component and may use only the `pi.tools` MCP tools. The caller owns the \
355 process, transport, transcript, and session lifecycle, and judges success from the event \
356 stream, never from the exit code.\n"
357 )
358}