1use std::collections::{BTreeMap, BTreeSet};
4use std::io::{self, IsTerminal, Write};
5use std::path::{Path, PathBuf};
6use std::sync::Arc;
7use std::time::{Duration, SystemTime, UNIX_EPOCH};
8
9use anyhow::{bail, Context};
10use orchestral_agent_journal_fs::FileAgentJournalStore;
11use orchestral_blob_fs::FileBlobStore;
12use orchestral_core::agent_connector::AgentSessionExecutionProfile;
13use orchestral_core::agent_protocol::{
14 reference::AgentRunStatus,
15 spi::{AgentJournalStore, InMemoryAgentJournalStore},
16 wire::{
17 AgentCommand, AgentCommandEnvelope, AgentRunState, AgentSessionId, AgentTelemetry,
18 AgentTerminalState, ApprovalDecision, BindingRequirement, CommandAckState, CommandId,
19 Content, ContentBody, Digest, PendingRequest, PendingRequestPayload, ProviderBindingRef,
20 RequestId, RequestResolution, ResourceBinding, ResourceBindingId, ResourceBindingMode,
21 ResourceKind, ResourceRef, ResourceRevision, RunId,
22 },
23};
24use orchestral_core::agent_session::{AgentSessionJournalStore, InMemoryAgentSessionJournalStore};
25use orchestral_core::config::{
26 load_config, BackendSpec, McpTransportSpec, ModelProfile, OrchestralConfig,
27};
28use orchestral_core::io::BlobStore;
29use orchestral_core::mcp_protocol::{McpServerId, McpTransportFactory};
30use orchestral_core::model_protocol::ModelBackend;
31use orchestral_core::session_history::{SessionHistory, SessionOrigin};
32use orchestral_core::skill_protocol::SKILL_CATALOG_RESOURCE_KIND_V1;
33use orchestral_core::tool_effect::{InMemoryToolEffectJournalStore, ToolEffectJournalStore};
34use orchestral_core::tool_protocol::{
35 ApprovalPolicy, EffectScope, EnvironmentPolicy, FilesystemPolicy, HostApprovalVerifier,
36 HostToolPolicy, InMemoryApprovalCapabilityStore, InteractiveCommandPolicy, NetworkPolicy,
37 ProcessPolicy, RunToolGrant, SandboxPolicy, ToolDescriptor, ToolPolicyBounds, ToolRestriction,
38 TransportLaunchPolicy,
39};
40use orchestral_mcp_streamable_http::{
41 ResolvedCredentialHeader, StreamableHttpMcpTransportConfig, StreamableHttpMcpTransportFactory,
42 DEFAULT_MAX_MCP_HTTP_FRAME_BYTES,
43};
44use orchestral_model_gemini::{
45 GeminiAuthentication, GeminiModelBackend, GeminiModelConfig, GoogleCloudAccessTokenProvider,
46};
47use orchestral_model_openai::{
48 OpenAiCompatibleBackend, OpenAiCompatibleConfig, OpenAiSamplingConfig, OpenAiToolResultFormat,
49};
50use orchestral_runtime::api::AgentApi;
51use orchestral_runtime::session_history::JournalSessionHistory;
52use orchestral_runtime::tools::{
53 approved_host_exec_command_descriptor, guarded_apply_patch_descriptor,
54 guarded_artifact_read_v2_descriptor, guarded_file_edit_descriptor,
55 guarded_file_read_descriptor, guarded_file_search_descriptor, guarded_file_write_descriptor,
56 guarded_session_read_descriptor, guarded_text_search_descriptor,
57 workspace_exec_command_descriptor, workspace_write_stdin_descriptor,
58 CommandEnvironmentSnapshot, GuardedApplyPatchExecutor, GuardedArtifactReadExecutor,
59 GuardedExecCommandExecutor, GuardedFileEditExecutor, GuardedFileReadExecutor,
60 GuardedFileSearchExecutor, GuardedFileWriteExecutor, GuardedSessionReadExecutor,
61 GuardedTextSearchExecutor, GuardedWriteStdinExecutor,
62};
63use orchestral_runtime::{
64 AgentClient, AgentControlEvent, AgentController, ContinuationPolicy,
65 DeterministicExtractiveSessionSummarizer, GenericAgentCheckpointStore, GenericAgentConfig,
66 GuardedMcpServerConfig, GuardedToolRuntime, InMemoryBlobStore,
67 InMemoryGenericAgentCheckpointStore, InMemoryHostApprovalBroker, InternalGenericAgentProvider,
68 McpToolsAdapterRegistry, ModelTokenMeter, ProcessSupervisor, SessionCompactionPolicy,
69 StdioMcpSandboxPolicy, StdioMcpTransportFactory, ToolArtifactStore, WorkspacePermissionPolicy,
70};
71use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
72use tokio_util::sync::CancellationToken;
73
74use crate::google_auth::{
75 google_adc_is_explicitly_requested, resolve_google_vertex_auth, GoogleCredentialSource,
76};
77use crate::runtime::client::prepare_runtime_config_path;
78use crate::runtime::ModelOverrides;
79use crate::skill_command::{build_skill_setup, SkillManager};
80
81#[derive(Clone)]
82pub struct AgentRunOptions {
83 pub config: Option<PathBuf>,
84 pub credential_file: Option<PathBuf>,
85 pub model_overrides: ModelOverrides,
86 pub session_id: Option<String>,
87 pub system_prompt: Option<String>,
88 pub input: Option<String>,
89 pub input_mode: InputMode,
90 pub no_mcp: bool,
91 pub mcp_config: Vec<PathBuf>,
92 pub no_skills: bool,
93 pub cwd: Option<PathBuf>,
94 pub add_dirs: Vec<PathBuf>,
95}
96
97#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, clap::ValueEnum)]
99pub enum InputMode {
100 #[default]
102 Auto,
103 Interactive,
105 None,
107}
108
109impl InputMode {
110 fn resolve(self, entry: &EntryMode, stdin_is_terminal: bool) -> anyhow::Result<bool> {
111 match (self, entry) {
112 (Self::Interactive, EntryMode::HeadlessPipe) => bail!(
113 "--input-mode interactive cannot use stdin for both the initial prompt and replies; \
114 pass the prompt as an argument to reserve stdin for replies"
115 ),
116 (Self::None, _) => Ok(false),
117 (Self::Interactive, _) | (Self::Auto, EntryMode::Tui) => Ok(true),
118 (Self::Auto, EntryMode::HeadlessPrompt(_)) => Ok(stdin_is_terminal),
119 (Self::Auto, EntryMode::HeadlessPipe) => Ok(false),
120 }
121 }
122}
123
124#[derive(Debug, Clone)]
125struct CliWorkspaceSet {
126 primary: PathBuf,
127 additional: Vec<PathBuf>,
128}
129
130impl CliWorkspaceSet {
131 fn resolve(primary: Option<&Path>, additional: &[PathBuf]) -> anyhow::Result<Self> {
132 let process_cwd = std::env::current_dir().context("resolve process directory")?;
133 let requested_primary = primary.unwrap_or(&process_cwd);
134 let primary = canonical_workspace_directory(requested_primary, "primary workspace")?;
135 let mut seen = BTreeSet::from([primary.clone()]);
136 let mut resolved_additional = Vec::new();
137 for requested in additional {
138 let requested = if requested.is_absolute() {
139 requested.clone()
140 } else {
141 primary.join(requested)
142 };
143 let resolved = canonical_workspace_directory(&requested, "additional workspace")?;
144 if seen.insert(resolved.clone()) {
145 resolved_additional.push(resolved);
146 }
147 }
148 Ok(Self {
149 primary,
150 additional: resolved_additional,
151 })
152 }
153
154 fn roots(&self) -> impl Iterator<Item = &PathBuf> {
155 std::iter::once(&self.primary).chain(self.additional.iter())
156 }
157
158 fn root_strings(&self) -> BTreeSet<String> {
159 self.roots()
160 .map(|root| root.to_string_lossy().into_owned())
161 .collect()
162 }
163
164 fn file_tool_descriptor(&self, mut descriptor: ToolDescriptor) -> ToolDescriptor {
165 if self.additional.is_empty() {
168 if let Some(properties) =
169 descriptor.model_schema.input_schema["properties"].as_object_mut()
170 {
171 properties.remove("workspace");
172 }
173 }
174 descriptor
175 }
176}
177
178fn canonical_workspace_directory(path: &Path, label: &str) -> anyhow::Result<PathBuf> {
179 let canonical = std::fs::canonicalize(path)
180 .with_context(|| format!("resolve {label} '{}'", path.display()))?;
181 if !canonical.is_dir() {
182 bail!("{label} is not a directory: '{}'", canonical.display());
183 }
184 Ok(canonical)
185}
186
187#[derive(Debug, Clone, PartialEq, Eq)]
188enum EntryMode {
189 HeadlessPrompt(String),
190 HeadlessPipe,
191 Tui,
192}
193
194#[derive(Clone)]
195struct CliJournalStores {
196 run: Arc<dyn AgentJournalStore>,
197 session: Arc<dyn AgentSessionJournalStore>,
198 effect: Arc<dyn ToolEffectJournalStore>,
199 checkpoint: Arc<dyn GenericAgentCheckpointStore>,
200}
201
202struct CliExecHost {
203 shell: PathBuf,
204 allow_host_execution: bool,
205 runtime_readable_roots: Vec<PathBuf>,
206 runtime_readable_files: Vec<PathBuf>,
207 environment_names: BTreeSet<String>,
208 environment: CommandEnvironmentSnapshot,
209 network_targets: BTreeSet<String>,
210}
211
212struct CliToolComposition {
213 runtime: Arc<GuardedToolRuntime<InMemoryApprovalCapabilityStore>>,
214 run_grant: RunToolGrant,
215 mcp_restriction: ToolRestriction,
216 approval_broker: Arc<InMemoryHostApprovalBroker>,
217 process_supervisor: Arc<ProcessSupervisor>,
218}
219
220#[derive(Clone)]
221pub(crate) struct HostMetadata {
222 pub workspaces: Vec<PathBuf>,
223 pub journal_location: String,
224 pub context: String,
225 pub context_budget: Option<u64>,
226 pub models: Vec<ModelProfile>,
227}
228
229pub struct AgentHost {
230 pub(crate) metadata: HostMetadata,
231 journals: CliJournalStores,
232 pub api: AgentApi,
233 pub approvals: Arc<InMemoryHostApprovalBroker>,
234 pub process_supervisor: Arc<ProcessSupervisor>,
235 pub backend_name: String,
236 pub model: String,
237 pub workspace_root: PathBuf,
238 pub execution_profile: AgentSessionExecutionProfile,
239 pub session_history: crate::local_sessions::LocalSessionHistory,
240 session_origin: SessionOrigin,
241 pub(crate) skill_manager: SkillManager,
242 controller: Arc<AgentController>,
243 resources: Vec<ResourceBinding>,
244 mcp_registry: McpToolsAdapterRegistry,
245 local_input_available: Option<bool>,
248}
249
250impl AgentHost {
251 pub fn client(&self, session_id: AgentSessionId) -> AgentClient {
252 AgentClient::new(self.controller.clone(), session_id)
253 .with_resources(self.resources.clone())
254 .with_default_extensions(self.session_origin.extensions())
255 }
256
257 pub(crate) async fn reconfigure(&self, options: &AgentRunOptions) -> anyhow::Result<Self> {
258 let mut next = build_agent_host_with_journals(
259 options,
260 Some(self.journals.clone()),
261 self.local_input_available,
262 )
263 .await?;
264 next.metadata.journal_location = self.metadata.journal_location.clone();
265 next.session_history = self.session_history.clone();
266 Ok(next)
267 }
268
269 pub(crate) async fn switch_session(&self, options: &AgentRunOptions) -> anyhow::Result<Self> {
270 if matches!(
271 self.session_history,
272 crate::local_sessions::LocalSessionHistory::Single(_)
273 ) {
274 self.reconfigure(options).await
275 } else {
276 build_agent_host_with_journals(options, None, self.local_input_available).await
277 }
278 }
279
280 pub async fn shutdown(&self) {
281 self.mcp_registry.shutdown().await;
282 }
283}
284
285pub async fn build_agent_host(options: &AgentRunOptions) -> anyhow::Result<AgentHost> {
286 build_agent_host_with_journals(options, None, None).await
287}
288
289async fn build_agent_host_with_journals(
290 options: &AgentRunOptions,
291 shared: Option<CliJournalStores>,
292 local_input_available: Option<bool>,
293) -> anyhow::Result<AgentHost> {
294 let workspaces = CliWorkspaceSet::resolve(options.cwd.as_deref(), &options.add_dirs)?;
295 let config_path = prepare_runtime_config_path(
296 options.config.clone(),
297 &options.model_overrides,
298 options.credential_file.as_deref(),
299 )?;
300 let config = load_config(&config_path)
301 .with_context(|| format!("load Generic Agent config '{}'", config_path.display()))?;
302 let (backend, profile, model, temperature) = resolve_model(&config).await?;
303 let (model_backend, token_meter) = build_model_backend(
304 &backend,
305 &model,
306 temperature,
307 profile.as_ref(),
308 config.agent.stream_buffer,
309 options.credential_file.as_deref(),
310 )?;
311
312 let mut agent_config = GenericAgentConfig::new("orchestral/internal", "generic-agent");
313 agent_config.input_requests_enabled =
314 config.agent.input_requests_enabled && local_input_available.unwrap_or(true);
315 agent_config.model_retry = config.agent.model_retry.clone();
316 agent_config.context_recovery = config.agent.context_recovery.clone();
317 {
318 use orchestral_core::project_instructions::ProjectInstructionSource;
319 use orchestral_project_instructions_fs::FileProjectInstructionSource;
320 let source = FileProjectInstructionSource::new(config.agent.project_instructions.clone())?;
321 agent_config.project_instructions = source
322 .snapshot(&workspaces.root_strings().into_iter().collect::<Vec<_>>())
323 .context("load project instruction snapshot")?;
324 for document in &agent_config.project_instructions {
325 tracing::info!(source = %document.source, scope = %document.scope, "loaded project instructions");
326 }
327 }
328 agent_config.stream_buffer = config.agent.stream_buffer;
329 agent_config.continuation = ContinuationPolicy {
330 max_model_steps: config.agent.max_model_steps,
331 max_tool_calls: config.agent.max_tool_calls,
332 };
333 agent_config.history_limit = config.agent.history_limit;
334 let declared_context_capacity = model_backend.descriptor().capabilities.max_context_tokens;
335 agent_config.max_context_tokens = declared_context_capacity
336 .map_or(config.agent.max_context_tokens, |capacity| {
337 capacity.min(config.agent.max_context_tokens)
338 });
339 agent_config.reserved_output_tokens = config.agent.reserved_output_tokens;
340 agent_config.minimum_output_reserve_tokens = config.agent.minimum_output_reserve_tokens;
341 if let Some(system_prompt) = options
342 .system_prompt
343 .clone()
344 .or_else(|| config.agent.system_prompt.clone())
345 .or_else(|| {
346 profile
347 .as_ref()
348 .and_then(|profile| profile.system_prompt.clone())
349 })
350 {
351 agent_config.system_prompt.push_str(
352 "\n\nAdditional Host-configured instructions (these refine, but do not replace, the Agent contract):\n",
353 );
354 agent_config.system_prompt.push_str(system_prompt.trim());
355 }
356 let workspace_context = serde_json::json!({
357 "primary": workspaces.primary,
358 "additional": workspaces.additional,
359 "os": std::env::consts::OS,
360 });
361 agent_config.system_prompt.push_str(&format!(
362 "\n\n<environment_context>\n <cwd>{}</cwd>\n <workspace_roots>{}</workspace_roots>\n</environment_context>",
363 workspaces.primary.display(),
364 workspace_context
365 ));
366 let base_journal_root = std::path::PathBuf::from(&config.journal.root_dir);
367 let journal_root = if local_input_available.is_some()
368 && matches!(config.journal.backend.as_str(), "fs" | "filesystem")
369 {
370 let id = options
371 .session_id
372 .as_deref()
373 .context("local Agent requires a session identity before opening storage")?;
374 crate::local_sessions::writer_root(&base_journal_root, &AgentSessionId::new(id)).await?
375 } else {
376 base_journal_root.clone()
377 };
378 let journals = if let Some(shared) = shared {
379 shared
380 } else {
381 match config.journal.backend.as_str() {
382 "memory" => CliJournalStores {
383 run: Arc::new(InMemoryAgentJournalStore::default()),
384 session: Arc::new(InMemoryAgentSessionJournalStore::default()),
385 effect: Arc::new(InMemoryToolEffectJournalStore::default()),
386 checkpoint: Arc::new(InMemoryGenericAgentCheckpointStore::default()),
387 },
388 "filesystem" | "fs" => {
389 let root = &journal_root;
390 let store = Arc::new(
391 FileAgentJournalStore::open_single_writer(root)
392 .with_context(|| format!("open Agent Journal at '{}'; if resuming, close the other terminal controlling this session", root.display()))?,
393 );
394 CliJournalStores {
395 run: store.clone(),
396 session: store.clone(),
397 effect: store.clone(),
398 checkpoint: store,
399 }
400 }
401 backend => bail!("unsupported Agent Journal backend for CLI: {backend}"),
402 }
403 };
404 let CliJournalStores {
405 run: run_journal,
406 session: session_journal,
407 effect: effect_journal,
408 checkpoint: generic_checkpoint_journal,
409 } = journals.clone();
410 let metadata = HostMetadata {
411 context_budget: declared_context_capacity.map(|_| agent_config.max_context_tokens),
412 workspaces: std::iter::once(workspaces.primary.clone()).chain(workspaces.additional.clone()).collect(),
413 journal_location: if config.journal.backend == "memory" { "In memory (this process only)".to_owned() } else { std::fs::canonicalize(&journal_root)?.display().to_string() },
414 context: format!("Declared model/server context limit: {}\nEffective Host budget: {} tokens\nReserved output: {} tokens\nCompaction: {}\n\nLoaded project instructions (Host snapshot):\n{}",
415 declared_context_capacity.map_or_else(|| "unknown (not reported or configured)".to_owned(), |capacity| format!("{capacity} tokens")),
416 agent_config.max_context_tokens, config.agent.reserved_output_tokens,
417 if config.agent.compaction.enabled { "automatic" } else { "disabled" },
418 agent_config.project_instructions.iter().map(|doc| format!("{}\n Scope: {}", doc.source, doc.scope)).collect::<Vec<_>>().join("\n")),
419 models: config.providers.models.clone(),
420 };
421 let session_history = if matches!(config.journal.backend.as_str(), "fs" | "filesystem") {
422 crate::local_sessions::LocalSessionHistory::Directory(base_journal_root)
423 } else {
424 crate::local_sessions::LocalSessionHistory::Single(JournalSessionHistory::new(
425 run_journal.clone(),
426 session_journal.clone(),
427 ProviderBindingRef::new(crate::local_sessions::GENERIC_BINDING),
428 ))
429 };
430 let mcp_configs = if options.no_mcp {
431 Vec::new()
432 } else {
433 configured_mcp_servers(&config, &options.mcp_config, &workspaces.primary)?
434 };
435 let artifact_store = ToolArtifactStore::new(
436 build_cli_blob_store(&config)?,
437 config.artifacts.max_bytes,
438 config.artifacts.summary_max_chars,
439 )
440 .context("configure Tool Artifact store")?
441 .with_inline_output_limit(model_inline_output_limit(
442 &config,
443 model_backend.descriptor().capabilities.max_context_tokens,
444 ));
445 let artifact_reader = GuardedArtifactReadExecutor::new_session_scoped(
446 artifact_store.clone(),
447 run_journal.clone(),
448 session_journal.clone(),
449 effect_journal.clone(),
450 );
451 let CliToolComposition {
452 runtime: tool_runtime,
453 run_grant,
454 mcp_restriction,
455 approval_broker,
456 process_supervisor,
457 } = build_cli_tool_runtime(
458 &config,
459 &mcp_configs,
460 effect_journal,
461 artifact_store,
462 &workspaces,
463 artifact_reader,
464 )?;
465 let mut recall_bounds = run_grant.bounds.clone();
468 recall_bounds.allowed_effects = BTreeSet::from([EffectScope::SessionRead]);
469 recall_bounds.process = ProcessPolicy::default();
470 recall_bounds.filesystem = FilesystemPolicy::default();
471 recall_bounds.network = NetworkPolicy::default();
472 recall_bounds.environment = EnvironmentPolicy::default();
473 recall_bounds.allowed_credentials.clear();
474 tool_runtime
475 .register(
476 guarded_session_read_descriptor(ToolRestriction {
477 bounds: recall_bounds,
478 }),
479 Arc::new(GuardedSessionReadExecutor::new(
480 run_journal.clone(),
481 session_journal.clone(),
482 )),
483 )
484 .context("register guarded session_read Tool")?;
485 let mcp_registry = McpToolsAdapterRegistry::register(
486 tool_runtime.as_ref(),
487 mcp_configs,
488 mcp_restriction,
489 CancellationToken::new(),
490 )
491 .await
492 .context("register guarded MCP stdio Tools")?;
493 for (server, error) in mcp_registry.skipped_optional_servers() {
494 tracing::warn!(server = %server, %error, "optional MCP server was unavailable");
495 }
496 let (skills, skill_manager) =
497 build_skill_setup(&config, &workspaces.primary, options.no_skills)?;
498 let provider = match skills.clone() {
499 Some(skills) => {
500 InternalGenericAgentProvider::new_with_tools_approval_skills_and_session_journal(
501 model_backend,
502 agent_config,
503 tool_runtime,
504 run_grant,
505 approval_broker.clone(),
506 skills,
507 session_journal,
508 token_meter,
509 )
510 }
511 None => InternalGenericAgentProvider::new_with_tools_approval_and_session_journal(
512 model_backend,
513 agent_config,
514 tool_runtime,
515 run_grant,
516 approval_broker.clone(),
517 session_journal,
518 token_meter,
519 ),
520 }
521 .context("create Generic Agent provider")?;
522 let provider = if config.agent.compaction.enabled {
523 provider
524 .with_session_compaction(
525 Arc::new(
526 DeterministicExtractiveSessionSummarizer::new(
527 config.agent.compaction.summary_max_chars,
528 )
529 .context("configure deterministic Session summarizer")?,
530 ),
531 SessionCompactionPolicy {
532 minimum_source_records: config.agent.compaction.minimum_source_records,
533 keep_recent_records: config.agent.compaction.keep_recent_records,
534 },
535 )
536 .context("bind Generic Agent Session compaction")?
537 } else {
538 provider
539 };
540 let provider = provider
541 .with_checkpoint_store(generic_checkpoint_journal)
542 .context("bind Generic Agent private checkpoint journal")?;
543 let provider = Arc::new(provider);
544 let controller = Arc::new(
545 AgentController::with_journal_store(
546 provider,
547 ProviderBindingRef::new("orchestral/generic-agent"),
548 run_journal,
549 )
550 .context("bind Generic Agent controller")?,
551 );
552 let mut resources = Vec::new();
553 if let Some(skills) = skills.as_deref() {
554 resources.push(ResourceBinding {
555 binding_id: ResourceBindingId::new("cli-skill-catalog"),
556 resource: ResourceRef {
557 kind: ResourceKind::new(SKILL_CATALOG_RESOURCE_KIND_V1),
558 id: skills.catalog().resource_id.clone(),
559 revision: ResourceRevision::new(skills.catalog().revision.as_str()),
560 },
561 requirement: BindingRequirement::Required,
562 mode: ResourceBindingMode::Snapshot,
563 });
564 }
565 let session_origin = SessionOrigin {
566 workspace: workspaces.primary.to_string_lossy().into_owned(),
567 additional_workspaces: workspaces
568 .additional
569 .iter()
570 .map(|path| path.to_string_lossy().into_owned())
571 .collect(),
572 model: model.clone(),
573 };
574 let api = AgentApi::with_resources(controller.clone(), resources.clone())
575 .with_default_extensions(session_origin.extensions());
576 Ok(AgentHost {
577 metadata,
578 journals,
579 api,
580 approvals: approval_broker,
581 process_supervisor,
582 backend_name: backend.name,
583 model: model.clone(),
584 workspace_root: workspaces.primary.clone(),
585 execution_profile: AgentSessionExecutionProfile {
586 model: Some(model),
587 reasoning_effort: Some("default".to_owned()),
588 permissions: Default::default(),
589 },
590 skill_manager,
591 session_history,
592 session_origin,
593 controller,
594 resources,
595 mcp_registry,
596 local_input_available,
597 })
598}
599
600pub async fn run(mut options: AgentRunOptions) -> anyhow::Result<()> {
601 let session_id = AgentSessionId::new(
602 options
603 .session_id
604 .get_or_insert_with(|| unique_id("cli-session", 0))
605 .clone(),
606 );
607 let stdin_is_terminal = io::stdin().is_terminal();
608 let entry_mode = select_entry_mode(
609 options.input.clone(),
610 stdin_is_terminal,
611 io::stdout().is_terminal(),
612 )?;
613 let local_input_available = options.input_mode.resolve(&entry_mode, stdin_is_terminal)?;
614 let mut host = Arc::new(
615 build_agent_host_with_journals(&options, None, Some(local_input_available)).await?,
616 );
617 let tui_options = options.clone();
618 let client = host.client(session_id.clone());
619
620 let result = async {
621 let history = host.session_history.read(&session_id).await?;
622 if let Some(history) = &history {
623 crate::local_sessions::validate_workspace(history, &host.session_origin.workspace)?;
624 eprintln!(
625 "Resuming session {} · {}",
626 session_id, history.summary.title
627 );
628 }
629 let resumed = resume_unfinished(&client, history.as_ref()).await?;
630 let history = host.session_history.read(&session_id).await?;
631 match entry_mode {
632 EntryMode::HeadlessPrompt(input) => {
633 eprintln!("Model: {} (provider: {})", host.model, host.backend_name);
634 let mut lines = BufReader::new(tokio::io::stdin()).lines();
635 run_turn(
636 &client,
637 &host.approvals,
638 1,
639 input,
640 &mut lines,
641 false,
642 resumed,
643 )
644 .await
645 }
646 EntryMode::HeadlessPipe => {
647 eprintln!("Model: {} (provider: {})", host.model, host.backend_name);
648 let mut input = String::new();
649 tokio::io::stdin()
650 .read_to_string(&mut input)
651 .await
652 .context("read piped Agent input")?;
653 if input.trim().is_empty() {
654 bail!("stdin pipe did not contain an Agent prompt")
655 }
656 let mut lines = BufReader::new(tokio::io::stdin()).lines();
657 run_turn(
658 &client,
659 &host.approvals,
660 1,
661 input,
662 &mut lines,
663 false,
664 resumed,
665 )
666 .await
667 }
668 EntryMode::Tui => {
669 crate::tui::run_tui(
670 client,
671 &mut host,
672 tui_options,
673 crate::tui::TuiResume {
674 history,
675 run: resumed,
676 },
677 )
678 .await
679 }
680 }
681 }
682 .await;
683 host.shutdown().await;
684 result
685}
686
687pub(crate) async fn resume_unfinished(
688 client: &AgentClient,
689 history: Option<&SessionHistory>,
690) -> anyhow::Result<Option<orchestral_runtime::AgentRunHandle>> {
691 let Some(history) = history else {
692 return Ok(None);
693 };
694 if history.summary.unfinished_run_ids.len() > 1 {
695 bail!("session has multiple unfinished executions; cannot select one for continuation");
696 }
697 let mut resumed = None;
698 for run_id in &history.summary.unfinished_run_ids {
699 let handle = client
700 .resume_run(run_id)
701 .await
702 .with_context(|| format!("reconcile unfinished Run {run_id}"))?;
703 if !handle.inspect().await?.state.is_terminal() {
704 resumed = Some(handle);
705 }
706 }
707 Ok(resumed)
708}
709
710fn model_inline_output_limit(
711 config: &OrchestralConfig,
712 backend_context_tokens: Option<u64>,
713) -> std::num::NonZeroU64 {
714 if let Some(limit) = config.tools.max_inline_output_bytes {
715 return limit;
716 }
717 let context = backend_context_tokens
718 .unwrap_or(config.agent.max_context_tokens)
719 .min(config.agent.max_context_tokens);
720 let bytes = context
723 .saturating_sub(config.agent.reserved_output_tokens)
724 .div_ceil(4)
725 .max(1);
726 std::num::NonZeroU64::new(bytes).expect("inline output allowance is positive")
727}
728
729fn build_cli_tool_runtime(
730 config: &OrchestralConfig,
731 mcp_configs: &[GuardedMcpServerConfig],
732 effect_journal: Arc<dyn ToolEffectJournalStore>,
733 artifact_store: ToolArtifactStore,
734 workspaces: &CliWorkspaceSet,
735 artifact_reader: GuardedArtifactReadExecutor,
736) -> anyhow::Result<CliToolComposition> {
737 let workspace_roots = workspaces.root_strings();
738 let exec_host = configured_exec_host(config)?;
739 let max_output_bytes = usize::try_from(config.tools.max_output_bytes).unwrap_or(usize::MAX);
740 let process_supervisor = Arc::new(
741 if exec_host.is_some() {
742 ProcessSupervisor::new_with_runtime_temp_root(
743 max_output_bytes,
744 command_runtime_temp_root(&workspace_roots)?,
745 )
746 } else {
747 ProcessSupervisor::new(max_output_bytes)
748 }
749 .context("create run-scoped process supervisor")?,
750 );
751 let runtime_temp_root = process_supervisor
752 .runtime_temp_root()
753 .to_string_lossy()
754 .into_owned();
755 let exec_programs = exec_host
756 .as_ref()
757 .map(|host| BTreeSet::from([host.shell.to_string_lossy().into_owned()]))
758 .unwrap_or_default();
759 let exec_enabled = exec_host.is_some();
760 let host_execution_enabled = exec_host
761 .as_ref()
762 .is_some_and(|host| host.allow_host_execution);
763 let mcp_effects = mcp_configs
764 .iter()
765 .flat_map(GuardedMcpServerConfig::effect_scopes)
766 .collect::<BTreeSet<_>>();
767 let transport_programs = mcp_configs
768 .iter()
769 .flat_map(GuardedMcpServerConfig::allowed_programs)
770 .collect::<BTreeSet<_>>();
771 let transport_allows_children = mcp_configs
772 .iter()
773 .any(GuardedMcpServerConfig::allows_child_processes);
774 let mcp_readable_roots = mcp_configs
775 .iter()
776 .flat_map(GuardedMcpServerConfig::filesystem_read_roots)
777 .collect::<BTreeSet<_>>();
778 let mcp_writable_roots = mcp_configs
779 .iter()
780 .flat_map(GuardedMcpServerConfig::filesystem_write_roots)
781 .collect::<BTreeSet<_>>();
782 let mut allowed_effects = BTreeSet::from([
783 EffectScope::FilesystemRead,
784 EffectScope::FilesystemWrite,
785 EffectScope::ArtifactRead,
786 EffectScope::SessionRead,
787 ]);
788 if exec_enabled {
789 allowed_effects.extend([
790 EffectScope::Process,
791 EffectScope::Network,
792 EffectScope::EnvironmentRead,
793 EffectScope::ExternalSideEffect,
794 ]);
795 }
796 if host_execution_enabled {
797 allowed_effects.insert(EffectScope::HostExecution);
798 }
799 allowed_effects.extend(mcp_effects.iter().copied());
800 let mcp_environment = mcp_configs
801 .iter()
802 .flat_map(GuardedMcpServerConfig::environment_names)
803 .collect::<BTreeSet<_>>();
804 let mut allowed_environment = mcp_environment.clone();
805 if let Some(host) = &exec_host {
806 allowed_environment.extend(host.environment_names.iter().cloned());
807 }
808 let mcp_network_targets = mcp_configs
809 .iter()
810 .flat_map(GuardedMcpServerConfig::allowed_network_targets)
811 .collect::<BTreeSet<_>>();
812 let mcp_allows_unrestricted_network = mcp_configs
813 .iter()
814 .any(GuardedMcpServerConfig::allows_unrestricted_network);
815 let mut allowed_network_targets = mcp_network_targets.clone();
816 if let Some(host) = &exec_host {
817 allowed_network_targets.extend(host.network_targets.iter().cloned());
818 }
819 let allowed_credentials = mcp_configs
820 .iter()
821 .flat_map(GuardedMcpServerConfig::credential_references)
822 .collect::<BTreeSet<_>>();
823 let mcp_sandbox_profiles = mcp_configs
824 .iter()
825 .flat_map(GuardedMcpServerConfig::sandbox_profiles)
826 .collect::<BTreeSet<_>>();
827 let mut readable_roots = workspace_roots.clone();
828 readable_roots.extend(mcp_readable_roots.iter().cloned());
829 let mut writable_roots = workspace_roots.clone();
830 writable_roots.extend(mcp_writable_roots.iter().cloned());
831 if exec_enabled {
832 readable_roots.insert(runtime_temp_root.clone());
833 writable_roots.insert(runtime_temp_root.clone());
834 }
835 let mcp_restriction = ToolRestriction {
836 bounds: ToolPolicyBounds {
837 allowed_effects: mcp_effects.clone(),
838 approval: ApprovalPolicy::NotRequired,
841 sandbox: SandboxPolicy {
842 required: !mcp_sandbox_profiles.is_empty(),
843 allowed_profiles: mcp_sandbox_profiles.clone(),
844 },
845 process: ProcessPolicy {
846 interactive: InteractiveCommandPolicy::default(),
847 transport: TransportLaunchPolicy {
848 allowed_programs: transport_programs.clone(),
849 allow_child_processes: transport_allows_children,
850 },
851 },
852 filesystem: FilesystemPolicy {
853 readable_roots: mcp_readable_roots,
854 writable_roots: mcp_writable_roots,
855 },
856 network: NetworkPolicy {
857 allowed_targets: mcp_network_targets,
858 allow_unrestricted: mcp_allows_unrestricted_network,
859 },
860 environment: EnvironmentPolicy {
861 allowed_variables: mcp_environment,
862 inherit_host_environment: false,
863 },
864 allowed_credentials: allowed_credentials.clone(),
865 max_timeout_ms: Some(config.tools.max_timeout_ms),
866 max_output_bytes: Some(config.tools.max_output_bytes),
867 },
868 };
869 let bounds = ToolPolicyBounds {
870 allowed_effects,
871 approval: ApprovalPolicy::NotRequired,
872 sandbox: SandboxPolicy {
873 required: false,
877 allowed_profiles: BTreeSet::from([
878 "workspace_read".to_owned(),
879 orchestral_runtime::tools::GUARDED_EXEC_SANDBOX_PROFILE.to_owned(),
880 ])
881 .union(&mcp_sandbox_profiles)
882 .cloned()
883 .collect(),
884 },
885 process: ProcessPolicy {
886 interactive: InteractiveCommandPolicy {
887 enabled: exec_enabled,
888 command_shells: exec_programs.clone(),
889 allow_child_processes: exec_enabled,
890 },
891 transport: TransportLaunchPolicy {
892 allowed_programs: transport_programs,
893 allow_child_processes: transport_allows_children,
894 },
895 },
896 filesystem: FilesystemPolicy {
897 readable_roots,
898 writable_roots,
899 },
900 network: NetworkPolicy {
901 allowed_targets: allowed_network_targets,
902 allow_unrestricted: host_execution_enabled || mcp_allows_unrestricted_network,
903 },
904 environment: EnvironmentPolicy {
905 allowed_variables: allowed_environment,
906 inherit_host_environment: false,
907 },
908 allowed_credentials,
909 max_timeout_ms: Some(config.tools.max_timeout_ms),
910 max_output_bytes: Some(config.tools.max_output_bytes),
911 };
912 let mut workspace_bounds = bounds.clone();
913 workspace_bounds.allowed_effects = BTreeSet::from([
914 EffectScope::FilesystemRead,
915 EffectScope::FilesystemWrite,
916 EffectScope::ArtifactRead,
917 ]);
918 workspace_bounds.sandbox.required = true;
919 workspace_bounds.sandbox.allowed_profiles = BTreeSet::from(["workspace_read".to_owned()]);
920 workspace_bounds.process = ProcessPolicy::default();
921 workspace_bounds.filesystem = FilesystemPolicy {
922 readable_roots: workspace_roots.clone(),
923 writable_roots: workspace_roots,
924 };
925 workspace_bounds.network = NetworkPolicy::default();
926 workspace_bounds.environment = EnvironmentPolicy::default();
927 workspace_bounds.allowed_credentials.clear();
928 let mut exec_bounds = bounds.clone();
932 exec_bounds.allowed_effects = BTreeSet::from([
933 EffectScope::Process,
934 EffectScope::Network,
935 EffectScope::FilesystemRead,
936 EffectScope::FilesystemWrite,
937 EffectScope::EnvironmentRead,
938 EffectScope::ExternalSideEffect,
939 ]);
940 if host_execution_enabled {
941 exec_bounds
942 .allowed_effects
943 .insert(EffectScope::HostExecution);
944 }
945 exec_bounds.sandbox.allowed_profiles =
946 BTreeSet::from([orchestral_runtime::tools::GUARDED_EXEC_SANDBOX_PROFILE.to_owned()]);
947 exec_bounds.sandbox.required = true;
948 exec_bounds.process.interactive = InteractiveCommandPolicy {
949 enabled: true,
950 command_shells: exec_programs,
951 allow_child_processes: true,
952 };
953 exec_bounds.process.transport = TransportLaunchPolicy::default();
954 exec_bounds.filesystem = workspace_bounds.filesystem.clone();
955 exec_bounds
956 .filesystem
957 .readable_roots
958 .insert(runtime_temp_root.clone());
959 exec_bounds
960 .filesystem
961 .writable_roots
962 .insert(runtime_temp_root);
963 exec_bounds.network = NetworkPolicy {
964 allowed_targets: exec_host
965 .as_ref()
966 .map(|host| host.network_targets.clone())
967 .unwrap_or_default(),
968 allow_unrestricted: host_execution_enabled,
969 };
970 exec_bounds.environment = EnvironmentPolicy {
971 allowed_variables: exec_host
972 .as_ref()
973 .map(|host| host.environment_names.clone())
974 .unwrap_or_default(),
975 inherit_host_environment: false,
976 };
977 exec_bounds.allowed_credentials.clear();
978 let mut signing_material = [0_u8; 32];
979 getrandom::fill(&mut signing_material)
980 .map_err(|error| anyhow::anyhow!("generate Host approval signing key: {error}"))?;
981 let approval_broker = Arc::new(
982 InMemoryHostApprovalBroker::new(signing_material).context("create Host approval broker")?,
983 );
984 let verifier =
985 HostApprovalVerifier::new(signing_material, InMemoryApprovalCapabilityStore::default())
986 .context("create Host approval verifier")?;
987 let runtime = Arc::new(
988 GuardedToolRuntime::new_with_effect_journal_and_artifacts(
989 HostToolPolicy {
990 bounds: bounds.clone(),
991 },
992 verifier,
993 effect_journal,
994 artifact_store.clone(),
995 )
996 .context("create guarded Tool Runtime")?
997 .with_permission_policy(Arc::new(WorkspacePermissionPolicy)),
998 );
999 runtime
1000 .register(
1001 guarded_artifact_read_v2_descriptor(ToolRestriction {
1002 bounds: workspace_bounds.clone(),
1003 }),
1004 Arc::new(artifact_reader),
1005 )
1006 .context("register guarded artifact_read Tool")?;
1007 runtime
1008 .register(
1009 workspaces.file_tool_descriptor(guarded_file_read_descriptor(ToolRestriction {
1010 bounds: workspace_bounds.clone(),
1011 })),
1012 Arc::new(
1013 GuardedFileReadExecutor::new_with_roots(
1014 &workspaces.primary,
1015 &workspaces.additional,
1016 )
1017 .context("open file_read workspace capability")?,
1018 ),
1019 )
1020 .context("register guarded file_read Tool")?;
1021 runtime
1022 .register(
1023 workspaces.file_tool_descriptor(guarded_file_search_descriptor(ToolRestriction {
1024 bounds: workspace_bounds.clone(),
1025 })),
1026 Arc::new(
1027 GuardedFileSearchExecutor::new_with_roots(
1028 &workspaces.primary,
1029 &workspaces.additional,
1030 )
1031 .context("open file_search workspace capability")?,
1032 ),
1033 )
1034 .context("register guarded file_search Tool")?;
1035 runtime
1036 .register(
1037 workspaces.file_tool_descriptor(guarded_text_search_descriptor(ToolRestriction {
1038 bounds: workspace_bounds.clone(),
1039 })),
1040 Arc::new(
1041 GuardedTextSearchExecutor::new_with_roots(
1042 &workspaces.primary,
1043 &workspaces.additional,
1044 )
1045 .context("open text_search workspace capability")?,
1046 ),
1047 )
1048 .context("register guarded text_search Tool")?;
1049 runtime
1050 .register(
1051 workspaces.file_tool_descriptor(guarded_file_write_descriptor(ToolRestriction {
1052 bounds: workspace_bounds.clone(),
1053 })),
1054 Arc::new(
1055 GuardedFileWriteExecutor::new_with_roots(
1056 &workspaces.primary,
1057 &workspaces.additional,
1058 )
1059 .context("open file_write workspace capability")?,
1060 ),
1061 )
1062 .context("register guarded file_write Tool")?;
1063 runtime
1064 .register(
1065 workspaces.file_tool_descriptor(guarded_file_edit_descriptor(ToolRestriction {
1066 bounds: workspace_bounds.clone(),
1067 })),
1068 Arc::new(
1069 GuardedFileEditExecutor::new_with_roots(
1070 &workspaces.primary,
1071 &workspaces.additional,
1072 )
1073 .context("open file_edit workspace capability")?,
1074 ),
1075 )
1076 .context("register guarded file_edit Tool")?;
1077 runtime
1078 .register(
1079 workspaces.file_tool_descriptor(guarded_apply_patch_descriptor(ToolRestriction {
1080 bounds: workspace_bounds,
1081 })),
1082 Arc::new(
1083 GuardedApplyPatchExecutor::new_with_roots(
1084 &workspaces.primary,
1085 &workspaces.additional,
1086 )
1087 .context("open apply_patch workspace capability")?,
1088 ),
1089 )
1090 .context("register guarded apply_patch Tool")?;
1091 if let Some(exec_host) = exec_host {
1092 let restriction = ToolRestriction {
1093 bounds: exec_bounds.clone(),
1094 };
1095 let mut descriptor = if config.tools.exec.sandboxed_execution_enabled {
1096 workspace_exec_command_descriptor(restriction)
1097 } else {
1098 approved_host_exec_command_descriptor(restriction)
1099 };
1100 let executor = GuardedExecCommandExecutor::new(
1101 process_supervisor.clone(),
1102 exec_host.shell,
1103 exec_host.runtime_readable_roots,
1104 exec_host.runtime_readable_files,
1105 exec_host.environment,
1106 )
1107 .and_then(|executor| {
1108 executor.with_pipeline_exit_status(config.tools.exec.pipeline_exit_status)
1109 })
1110 .map_err(anyhow::Error::msg)
1111 .context("configure guarded exec_command Tool")?
1112 .with_sandboxed_execution_enabled(config.tools.exec.sandboxed_execution_enabled);
1113 descriptor.model_schema.description.push_str(if executor.enables_pipefail() {
1114 " Pipefail is enabled: a pipeline returns its rightmost nonzero status, including SIGPIPE from an early-exiting consumer."
1115 } else {
1116 " Pipeline exit status uses native shell behavior; a successful last stage may hide an earlier failure."
1117 });
1118 runtime
1119 .register(descriptor, Arc::new(executor))
1120 .context("register guarded exec_command Tool")?;
1121 runtime
1122 .register(
1123 workspace_write_stdin_descriptor(ToolRestriction {
1124 bounds: exec_bounds,
1125 }),
1126 Arc::new(GuardedWriteStdinExecutor::new(process_supervisor.clone())),
1127 )
1128 .context("register guarded write_stdin Tool")?;
1129 } else {
1130 tracing::warn!("Generic Agent command execution is disabled by Host config");
1131 }
1132 Ok(CliToolComposition {
1133 runtime,
1134 run_grant: RunToolGrant { bounds },
1135 mcp_restriction,
1136 approval_broker,
1137 process_supervisor,
1138 })
1139}
1140
1141fn command_runtime_temp_root(workspace_roots: &BTreeSet<String>) -> anyhow::Result<PathBuf> {
1142 let candidates = vec![
1145 #[cfg(unix)]
1146 PathBuf::from("/tmp"),
1147 #[cfg(unix)]
1148 PathBuf::from("/var/tmp"),
1149 std::env::temp_dir(),
1150 ];
1151 let parent = candidates
1152 .into_iter()
1153 .filter_map(|path| std::fs::canonicalize(path).ok())
1154 .find(|path| path.is_dir() && !workspace_roots.iter().any(|root| path.starts_with(root)))
1155 .context("no OS temporary directory is available outside the workspace roots")?;
1156 let identity =
1157 orchestral_core::agent_protocol::wire::Digest::sha256(serde_json::to_vec(workspace_roots)?);
1158 Ok(parent.join(format!("orch-{}", &identity.as_str()[..24])))
1159}
1160
1161fn build_cli_blob_store(config: &OrchestralConfig) -> anyhow::Result<Arc<dyn BlobStore>> {
1162 match config.artifacts.backend.trim() {
1163 "memory" | "in_memory" => Ok(Arc::new(InMemoryBlobStore::default())),
1164 "local" | "filesystem" | "fs" => Ok(Arc::new(
1165 FileBlobStore::open(&config.artifacts.root_dir).with_context(|| {
1166 format!("open Artifact BlobStore at '{}'", config.artifacts.root_dir)
1167 })?,
1168 )),
1169 mode => bail!("unsupported BlobStore mode for Generic Agent Artifact results: {mode}"),
1170 }
1171}
1172
1173fn configured_exec_host(config: &OrchestralConfig) -> anyhow::Result<Option<CliExecHost>> {
1174 if !config.tools.exec.enabled {
1175 return Ok(None);
1176 }
1177 if !config.tools.exec.sandboxed_execution_enabled && !config.tools.exec.allow_host_execution {
1178 bail!("tools.exec.sandboxed_execution_enabled=false requires tools.exec.allow_host_execution=true; command approval is still required");
1179 }
1180 let configured = config
1181 .tools
1182 .exec
1183 .shell
1184 .as_deref()
1185 .filter(|shell| !shell.trim().is_empty());
1186 let shell = if let Some(shell) = configured {
1187 PathBuf::from(resolve_host_program(shell)?)
1188 } else if let Some(shell) = std::env::var_os("SHELL").filter(|shell| !shell.is_empty()) {
1189 PathBuf::from(resolve_host_program(&shell.to_string_lossy())?)
1190 } else {
1191 #[cfg(windows)]
1192 let candidates = {
1193 let mut paths = vec!["pwsh.exe".to_owned(), "powershell.exe".to_owned()];
1194 if let Some(root) = std::env::var_os("SystemRoot") {
1195 paths.push(
1196 PathBuf::from(root)
1197 .join("System32/WindowsPowerShell/v1.0/powershell.exe")
1198 .to_string_lossy()
1199 .into_owned(),
1200 );
1201 }
1202 paths.push("cmd.exe".to_owned());
1203 paths
1204 };
1205 #[cfg(not(windows))]
1206 let candidates = ["/bin/zsh", "/bin/bash", "/bin/sh"];
1207 candidates
1208 .iter()
1209 .find_map(|candidate| resolve_host_program(candidate).ok())
1210 .map(PathBuf::from)
1211 .context("no command shell is available; set tools.exec.shell")?
1212 };
1213 let environment_names = [
1214 "PATH",
1215 "HOME",
1216 "USERPROFILE",
1217 "SystemRoot",
1218 "SYSTEMROOT",
1219 "WINDIR",
1220 "COMSPEC",
1221 "PATHEXT",
1222 "APPDATA",
1223 "LOCALAPPDATA",
1224 "USER",
1225 "LANG",
1226 "LC_ALL",
1227 "TERM",
1228 "COLORTERM",
1229 "NO_COLOR",
1230 "CARGO_HOME",
1231 "RUSTUP_HOME",
1232 "XDG_CONFIG_HOME",
1233 "GIT_CONFIG_GLOBAL",
1234 "GIT_CONFIG_SYSTEM",
1235 "GIT_CONFIG_NOSYSTEM",
1236 "HTTP_PROXY",
1237 "HTTPS_PROXY",
1238 "ALL_PROXY",
1239 "NO_PROXY",
1240 "http_proxy",
1241 "https_proxy",
1242 "all_proxy",
1243 "no_proxy",
1244 ]
1245 .into_iter()
1246 .map(str::to_owned)
1247 .collect::<BTreeSet<_>>();
1248 Ok(Some(CliExecHost {
1249 allow_host_execution: config.tools.exec.allow_host_execution,
1250 runtime_readable_roots: exec_runtime_readable_roots(&shell),
1251 runtime_readable_files: exec_runtime_readable_files(),
1252 shell,
1253 environment: CommandEnvironmentSnapshot::capture(environment_names.iter().cloned()),
1254 environment_names,
1255 network_targets: config.tools.exec.network_targets.iter().cloned().collect(),
1256 }))
1257}
1258
1259fn exec_runtime_readable_files() -> Vec<PathBuf> {
1260 let mut candidates = orchestral_runtime::tools::host_toolchain_readable_files()
1261 .into_iter()
1262 .collect::<BTreeSet<_>>();
1263 let home = std::env::var_os("HOME").map(PathBuf::from);
1264 if let Some(home) = &home {
1265 candidates.insert(home.join(".gitconfig"));
1266 let xdg_home = std::env::var_os("XDG_CONFIG_HOME")
1267 .map(PathBuf::from)
1268 .filter(|path| path.is_absolute())
1269 .unwrap_or_else(|| home.join(".config"));
1270 candidates.insert(xdg_home.join("git/config"));
1271 }
1272 for name in ["GIT_CONFIG_GLOBAL", "GIT_CONFIG_SYSTEM"] {
1273 if let Some(path) = std::env::var_os(name).map(PathBuf::from) {
1274 if path.is_absolute() {
1275 candidates.insert(path);
1276 }
1277 }
1278 }
1279 candidates
1280 .into_iter()
1281 .filter_map(|path| std::fs::canonicalize(path).ok())
1282 .filter(|path| path.is_file())
1283 .collect()
1284}
1285
1286fn exec_runtime_readable_roots(shell: &Path) -> Vec<PathBuf> {
1287 let mut candidates = BTreeSet::new();
1288 if let Some(parent) = shell.parent() {
1289 candidates.insert(parent.to_path_buf());
1290 }
1291 if let Some(path) = std::env::var_os("PATH") {
1292 for directory in std::env::split_paths(&path).filter(|path| path.is_absolute()) {
1293 candidates.insert(directory.clone());
1294 let text = directory.to_string_lossy();
1295 if ["/.nvm/versions/", "/.pyenv/versions/"]
1299 .iter()
1300 .any(|marker| text.contains(marker))
1301 && directory.file_name().is_some_and(|name| name == "bin")
1302 {
1303 if let Some(installation) = directory.parent() {
1304 candidates.insert(installation.to_path_buf());
1305 }
1306 }
1307 if text.starts_with("/opt/homebrew/") {
1308 candidates.insert(PathBuf::from("/opt/homebrew"));
1309 }
1310 if text.starts_with("/nix/store/") {
1311 candidates.insert(PathBuf::from("/nix/store"));
1312 }
1313 }
1314 }
1315 for candidate in [
1316 "/bin",
1317 "/usr/bin",
1318 "/usr/sbin",
1319 "/usr/local",
1320 "/opt/homebrew",
1321 "/nix/store",
1322 "/etc",
1323 "/Library/Frameworks",
1324 "/Library/Developer/CommandLineTools",
1325 "/Applications/Xcode.app",
1326 ] {
1327 candidates.insert(PathBuf::from(candidate));
1328 }
1329 if let Some(home) = std::env::var_os("HOME") {
1330 let home = PathBuf::from(home);
1331 candidates.insert(home.join(".cargo/bin"));
1332 candidates.insert(home.join(".cargo/registry"));
1333 candidates.insert(home.join(".cargo/git"));
1334 candidates.insert(home.join(".rustup"));
1335 }
1336 candidates
1337 .into_iter()
1338 .filter_map(|path| std::fs::canonicalize(path).ok())
1339 .filter(|path| path.is_dir())
1340 .collect()
1341}
1342
1343fn configured_mcp_servers(
1346 config: &OrchestralConfig,
1347 cli_manifest_paths: &[PathBuf],
1348 workspace: &Path,
1349) -> anyhow::Result<Vec<GuardedMcpServerConfig>> {
1350 if !config.mcp.enabled {
1351 return Ok(Vec::new());
1352 }
1353 let mut trusted_user_paths = Vec::new();
1354 match crate::mcp_config::user_registry_path() {
1355 Ok(path) if path.is_file() => trusted_user_paths.push(path),
1356 Ok(_) => {}
1357 Err(error) => tracing::debug!(%error, "user MCP registry path is unavailable"),
1358 }
1359 let specs = crate::mcp_config::load_server_manifests(
1360 workspace,
1361 &config.mcp.servers,
1362 &config.mcp.import_files,
1363 &trusted_user_paths,
1364 cli_manifest_paths,
1365 )?;
1366 let mut servers = Vec::new();
1367 for spec in specs.iter().filter(|server| server.enabled) {
1368 let transport = (|| -> anyhow::Result<Arc<dyn McpTransportFactory>> {
1369 match &spec.transport {
1370 McpTransportSpec::Stdio {
1371 command,
1372 args,
1373 env,
1374 allow_child_processes,
1375 allow_host_ui,
1376 cwd,
1377 readable_roots,
1378 writable_roots,
1379 network_targets,
1380 allow_unrestricted_network,
1381 } => {
1382 let program = resolve_host_program(command)?;
1383 let cwd =
1384 resolve_mcp_directory(workspace, cwd.as_deref().unwrap_or("."), "cwd")?;
1385 let mut reads =
1386 resolve_mcp_directories(workspace, readable_roots, "readable root")?;
1387 reads.insert(cwd.clone());
1388 let mut writes =
1389 resolve_mcp_directories(workspace, writable_roots, "writable root")?;
1390 let runtime_root = prepare_mcp_runtime_root(workspace, &spec.name)?;
1391 writes.insert(runtime_root.clone());
1392 Ok(Arc::new(StdioMcpTransportFactory::new(
1393 PathBuf::from(program),
1394 args.clone(),
1395 env.iter()
1396 .map(|(key, value)| (key.clone(), value.clone()))
1397 .collect(),
1398 StdioMcpSandboxPolicy::scoped(
1399 cwd,
1400 reads,
1401 writes,
1402 network_targets.iter().cloned().collect(),
1403 )
1404 .with_unrestricted_network(*allow_unrestricted_network)
1405 .with_child_processes(*allow_child_processes)
1406 .with_host_ui(*allow_host_ui)
1407 .with_private_runtime_home(runtime_root),
1408 )?))
1409 }
1410 McpTransportSpec::StreamableHttp {
1411 endpoint,
1412 credential_headers,
1413 max_frame_bytes,
1414 } => {
1415 let mut resolved = BTreeMap::new();
1416 for (header, credential) in credential_headers {
1417 let env_name = credential.env.trim();
1418 let value = std::env::var(env_name).with_context(|| {
1419 format!(
1420 "resolve credential environment variable '{env_name}' for header '{header}'"
1421 )
1422 })?;
1423 resolved.insert(
1424 header.clone(),
1425 ResolvedCredentialHeader {
1426 reference: format!("env:{env_name}"),
1427 value,
1428 },
1429 );
1430 }
1431 Ok(Arc::new(StreamableHttpMcpTransportFactory::new(
1432 StreamableHttpMcpTransportConfig {
1433 endpoint: endpoint.clone(),
1434 credential_headers: resolved,
1435 max_frame_bytes: max_frame_bytes
1436 .unwrap_or(DEFAULT_MAX_MCP_HTTP_FRAME_BYTES),
1437 },
1438 )?))
1439 }
1440 }
1441 })();
1442 let transport = match transport {
1443 Ok(transport) => transport,
1444 Err(error) if !spec.required => {
1445 tracing::warn!(server = spec.name, %error, "optional MCP transport was unavailable");
1446 continue;
1447 }
1448 Err(error) => {
1449 return Err(error)
1450 .with_context(|| format!("compose required MCP server '{}'", spec.name))
1451 }
1452 };
1453 let server = GuardedMcpServerConfig {
1454 server_id: McpServerId::new(spec.name.trim()),
1455 required: spec.required,
1456 transport,
1457 startup_timeout: Duration::from_millis(spec.startup_timeout_ms.unwrap_or(15_000)),
1458 tool_timeout: Duration::from_millis(spec.tool_timeout_ms.unwrap_or(120_000)),
1462 enabled_tools: spec.enabled_tools.iter().cloned().collect(),
1463 disabled_tools: spec.disabled_tools.iter().cloned().collect(),
1464 };
1465 if let Err(error) = server.validate() {
1466 if !spec.required {
1467 tracing::warn!(server = spec.name, %error, "optional MCP server config was invalid");
1468 continue;
1469 }
1470 return Err(error)
1471 .with_context(|| format!("validate required MCP server '{}'", spec.name));
1472 }
1473 servers.push(server);
1474 }
1475 Ok(servers)
1476}
1477
1478fn resolve_mcp_directories(
1479 workspace: &Path,
1480 configured: &[String],
1481 label: &str,
1482) -> anyhow::Result<BTreeSet<PathBuf>> {
1483 configured
1484 .iter()
1485 .map(|path| resolve_mcp_directory(workspace, path, label))
1486 .collect()
1487}
1488
1489fn resolve_mcp_directory(
1490 workspace: &Path,
1491 configured: &str,
1492 label: &str,
1493) -> anyhow::Result<PathBuf> {
1494 let path = PathBuf::from(configured);
1495 let path = if path.is_absolute() {
1496 path
1497 } else {
1498 workspace.join(path)
1499 };
1500 let canonical = std::fs::canonicalize(&path)
1501 .with_context(|| format!("canonicalize MCP stdio {label} '{}'", path.display()))?;
1502 if !canonical.is_dir() {
1503 bail!("MCP stdio {label} '{}' is not a directory", path.display());
1504 }
1505 Ok(canonical)
1506}
1507
1508fn prepare_mcp_runtime_root(workspace: &Path, server_name: &str) -> anyhow::Result<PathBuf> {
1509 let identity = Digest::sha256(server_name.as_bytes());
1510 let root = workspace
1511 .join(".orchestral/mcp")
1512 .join(&identity.as_str()[..16]);
1513 std::fs::create_dir_all(&root)
1514 .with_context(|| format!("create MCP runtime directory '{}'", root.display()))?;
1515 std::fs::canonicalize(&root)
1516 .with_context(|| format!("canonicalize MCP runtime directory '{}'", root.display()))
1517}
1518
1519fn resolve_host_program(program: &str) -> anyhow::Result<String> {
1520 let candidate = locate_host_program(program)?;
1521 std::fs::canonicalize(&candidate)
1522 .with_context(|| format!("canonicalize executable '{}'", candidate.display()))
1523 .map(|path| path.to_string_lossy().to_string())
1524}
1525
1526fn locate_host_program(program: &str) -> anyhow::Result<PathBuf> {
1527 let candidate = PathBuf::from(program);
1528 if candidate.is_absolute() {
1529 if host_executable_file(&candidate) {
1530 return Ok(candidate);
1531 }
1532 bail!("executable is unavailable: {}", candidate.display())
1533 }
1534 if program.contains(std::path::MAIN_SEPARATOR) || program.trim().is_empty() {
1535 bail!("program must be an absolute path or a bare executable name")
1536 }
1537 let path = std::env::var_os("PATH").context("PATH is unavailable")?;
1538 for directory in std::env::split_paths(&path) {
1539 let candidate = directory.join(program);
1540 if host_executable_file(&candidate) {
1541 return if candidate.is_absolute() {
1542 Ok(candidate)
1543 } else {
1544 Ok(std::env::current_dir()
1545 .context("resolve current directory for relative PATH entry")?
1546 .join(candidate))
1547 };
1548 }
1549 }
1550 bail!("executable was not found on Host PATH")
1551}
1552
1553fn host_executable_file(candidate: &Path) -> bool {
1554 let Ok(metadata) = candidate.metadata() else {
1555 return false;
1556 };
1557 if !metadata.is_file() {
1558 return false;
1559 }
1560 #[cfg(unix)]
1561 {
1562 use std::os::unix::fs::PermissionsExt;
1563 metadata.permissions().mode() & 0o111 != 0
1564 }
1565 #[cfg(not(unix))]
1566 {
1567 true
1568 }
1569}
1570
1571async fn resolve_model(
1572 config: &OrchestralConfig,
1573) -> anyhow::Result<(
1574 orchestral_core::config::BackendSpec,
1575 Option<ModelProfile>,
1576 String,
1577 f32,
1578)> {
1579 let profile = config
1580 .agent
1581 .model_profile
1582 .as_deref()
1583 .map(|name| {
1584 config
1585 .providers
1586 .get_model(name)
1587 .with_context(|| format!("model profile not found: {name}"))
1588 })
1589 .transpose()?;
1590 let backend_name = config
1591 .agent
1592 .backend
1593 .clone()
1594 .or_else(|| profile.as_ref().map(|profile| profile.backend.clone()))
1595 .or_else(|| config.providers.default_backend.clone());
1596 let backend = match backend_name {
1597 Some(name) => config
1598 .providers
1599 .get_backend(&name)
1600 .with_context(|| format!("model backend not found: {name}"))?,
1601 None => config
1602 .providers
1603 .get_default_backend()
1604 .context("no model backend configured")?,
1605 };
1606 let model = config
1607 .agent
1608 .model
1609 .clone()
1610 .or_else(|| profile.as_ref().map(|profile| profile.model.clone()));
1611 let (backend, model) = crate::openai_connection::resolve_model(backend, model).await?;
1612 let candidate = config
1613 .agent
1614 .temperature
1615 .or_else(|| profile.as_ref().and_then(|profile| profile.temperature))
1616 .unwrap_or(0.2);
1617 let temperature = profile
1618 .as_ref()
1619 .map(|profile| profile.clamp_temperature(candidate))
1620 .unwrap_or(candidate);
1621 Ok((backend, profile, model, temperature))
1622}
1623
1624pub(crate) fn build_model_backend(
1625 backend: &BackendSpec,
1626 model: &str,
1627 temperature: f32,
1628 profile: Option<&ModelProfile>,
1629 max_buffered_events: usize,
1630 credential_file: Option<&std::path::Path>,
1631) -> anyhow::Result<(Arc<dyn ModelBackend>, Arc<dyn ModelTokenMeter>)> {
1632 let max_output_tokens = profile
1633 .and_then(|profile| profile.max_tokens)
1634 .unwrap_or(8_192) as u64;
1635 let timeout = Duration::from_secs(
1636 backend
1637 .get_config("stream_idle_timeout_secs")
1638 .or_else(|| backend.get_config("timeout_secs"))
1639 .unwrap_or(300),
1640 );
1641 let max_context_tokens = backend.get_config("max_context_tokens");
1642 match backend.kind.trim().to_ascii_lowercase().as_str() {
1643 "google" | "gemini" => {
1644 let auth_mode = backend
1645 .get_config::<String>("auth")
1646 .unwrap_or_else(|| "auto".to_owned())
1647 .trim()
1648 .to_ascii_lowercase();
1649 if !matches!(auth_mode.as_str(), "auto" | "api_key" | "adc") {
1650 bail!(
1651 "unsupported Google auth mode '{auth_mode}'; expected auto, api_key, or adc"
1652 );
1653 }
1654 let explicit_adc = auth_mode == "adc"
1655 || google_adc_is_explicitly_requested(credential_file, backend);
1656 let api_key = (auth_mode != "adc")
1657 .then(|| backend.resolve_api_key().ok())
1658 .flatten();
1659 let vertex_plan = if explicit_adc || api_key.is_none() {
1660 resolve_google_vertex_auth(credential_file, backend)?
1661 } else {
1662 None
1663 };
1664 let (authentication, endpoint) = if let Some(plan) = vertex_plan {
1665 let provider = match &plan.source {
1666 GoogleCredentialSource::ServiceAccountFile(path) => {
1667 GoogleCloudAccessTokenProvider::from_service_account_file(path)
1668 }
1669 GoogleCredentialSource::ApplicationDefault => {
1670 GoogleCloudAccessTokenProvider::application_default()
1671 }
1672 }
1673 .context("initialize Google Cloud authentication")?;
1674 (
1675 GeminiAuthentication::AccessTokenProvider(Arc::new(provider)),
1676 backend.endpoint.clone().unwrap_or_else(|| plan.endpoint()),
1677 )
1678 } else if let Some(api_key) = api_key {
1679 (
1680 GeminiAuthentication::ApiKey(api_key),
1681 backend.endpoint.clone().unwrap_or_else(|| {
1682 "https://generativelanguage.googleapis.com/v1beta".to_owned()
1683 }),
1684 )
1685 } else {
1686 bail!(
1687 "no Google credentials found for backend '{}'; use --credential-file, \
1688 GOOGLE_APPLICATION_CREDENTIALS, `gcloud auth application-default login`, \
1689 or GOOGLE_API_KEY",
1690 backend.name
1691 );
1692 };
1693 let backend = Arc::new(
1694 GeminiModelBackend::new(GeminiModelConfig {
1695 backend_id: format!("google-gemini/{}", backend.name),
1696 endpoint,
1697 authentication,
1698 model: model.to_owned(),
1699 temperature,
1700 thinking_level: None,
1701 default_max_output_tokens: max_output_tokens,
1702 max_context_tokens,
1703 timeout,
1704 max_buffered_events,
1705 })
1706 .context("build Gemini ModelBackend")?,
1707 );
1708 Ok((backend.clone(), backend))
1709 }
1710 "openai" | "openrouter" | "deepseek" | "groq" | "xai" | "mistral" => {
1711 let sampling = profile
1712 .and_then(|profile| profile.config.get("sampling"))
1713 .map(|value| serde_json::from_value::<OpenAiSamplingConfig>(value.clone()))
1714 .transpose()
1715 .context("parse model profile config.sampling")?
1716 .unwrap_or_default();
1717 let tool_result_format = profile
1718 .and_then(|profile| profile.config.get("tool_result_format"))
1719 .map(|value| serde_json::from_value::<OpenAiToolResultFormat>(value.clone()))
1720 .transpose()
1721 .context("parse model profile config.tool_result_format")?
1722 .unwrap_or_default();
1723 let api_key = crate::openai_connection::api_key(backend)?;
1724 let endpoint = backend.endpoint.clone().or_else(|| match backend.kind.as_str() {
1725 "openai" => Some("https://api.openai.com/v1".to_owned()),
1726 "deepseek" => Some("https://api.deepseek.com".to_owned()),
1727 _ => None,
1728 }).with_context(|| {
1729 format!(
1730 "OpenAI-compatible backend '{}' requires an endpoint",
1731 backend.name
1732 )
1733 })?;
1734 let backend = Arc::new(
1735 OpenAiCompatibleBackend::new(OpenAiCompatibleConfig {
1736 backend_id: format!("openai-compatible/{}", backend.name),
1737 endpoint,
1738 api_key,
1739 model: model.to_owned(),
1740 temperature,
1741 default_max_output_tokens: max_output_tokens,
1742 max_context_tokens,
1743 timeout,
1744 structured_output: backend
1745 .get_config("structured_output")
1746 .unwrap_or(true),
1747 max_buffered_events,
1748 })
1749 .context("build OpenAI-compatible ModelBackend")?
1750 .with_sampling(sampling)
1751 .context("configure OpenAI-compatible sampling")?
1752 .with_tool_result_format(tool_result_format),
1753 );
1754 Ok((backend.clone(), backend))
1755 }
1756 kind => bail!(
1757 "unsupported ModelBackend kind '{kind}'; supported protocol families are OpenAI-compatible and Gemini Native"
1758 ),
1759 }
1760}
1761
1762async fn start_or_continue_turn(
1763 client: &AgentClient,
1764 resumed: Option<orchestral_runtime::AgentRunHandle>,
1765 input: String,
1766 turn: u64,
1767) -> anyhow::Result<(orchestral_runtime::AgentRunHandle, Option<RequestId>)> {
1768 if let Some(handle) = resumed {
1769 let view = handle.inspect().await?;
1770 if !view.state.is_terminal() {
1771 let submitted_request = view
1772 .pending_requests
1773 .iter()
1774 .find(|request| matches!(request.payload, PendingRequestPayload::Input { .. }))
1775 .map(|request| request.request_id.clone());
1776 let ack = if let Some(request_id) = &submitted_request {
1777 handle
1778 .resolve_input_text(request_id.clone(), input.clone())
1779 .await
1780 } else {
1781 handle.steer_text(input.clone()).await
1782 };
1783 match ack {
1784 Ok(ack)
1785 if matches!(
1786 ack.state,
1787 CommandAckState::Accepted { .. } | CommandAckState::Applied { .. }
1788 ) =>
1789 {
1790 return Ok((handle, submitted_request))
1791 }
1792 Ok(ack) => {
1793 if !matches!(
1794 ack.state,
1795 CommandAckState::Rejected { .. } | CommandAckState::Unsupported { .. }
1796 ) || !handle.inspect().await?.state.is_terminal()
1797 {
1798 bail!(
1799 "resumed Run did not accept the follow-up input: {:?}",
1800 ack.state
1801 );
1802 }
1803 }
1804 Err(error) => return Err(error.into()),
1805 }
1806 }
1810 }
1811 client
1812 .start_with_run_id(
1813 RunId::new(unique_id("cli-run", turn)),
1814 vec![Content::text(input)],
1815 )
1816 .await
1817 .context("start Agent Run")
1818 .map(|handle| (handle, None))
1819}
1820
1821async fn run_turn(
1822 client: &AgentClient,
1823 approval_broker: &Arc<InMemoryHostApprovalBroker>,
1824 turn: u64,
1825 input: String,
1826 lines: &mut tokio::io::Lines<BufReader<tokio::io::Stdin>>,
1827 accept_unsolicited_stdin: bool,
1828 resumed: Option<orchestral_runtime::AgentRunHandle>,
1829) -> anyhow::Result<()> {
1830 let (handle, submitted_request) = start_or_continue_turn(client, resumed, input, turn).await?;
1831 let run_id = handle.run_id().clone();
1832 let mut events = handle.subscribe().await.context("subscribe to Agent Run")?;
1833 let mut handled_requests = submitted_request.into_iter().collect::<BTreeSet<_>>();
1837 let mut stdin_open = true;
1838 let view = loop {
1839 let view = handle.inspect().await.context("inspect Agent Run")?;
1840 if is_terminal(view.state.status()) {
1841 break view;
1842 }
1843 if let Some(request) = view
1844 .pending_requests
1845 .iter()
1846 .find(|request| !handled_requests.contains(&request.request_id))
1847 .cloned()
1848 {
1849 handled_requests.insert(request.request_id.clone());
1850 if !resolve_cli_request(
1851 client.controller(),
1852 approval_broker,
1853 &run_id,
1854 request,
1855 lines,
1856 )
1857 .await?
1858 {
1859 continue;
1860 }
1861 continue;
1862 }
1863 tokio::select! {
1864 event = events.recv() => match event {
1865 Ok(AgentControlEvent::Telemetry(telemetry)) => match telemetry.payload {
1866 AgentTelemetry::OutputDelta { .. } => {}
1867 AgentTelemetry::ProgressReported { message, fraction } => {
1868 if let Some(fraction) = fraction {
1869 eprintln!("\n[{:.0}%] {message}", fraction * 100.0);
1870 } else {
1871 eprintln!("\n{message}");
1872 }
1873 }
1874 _ => {}
1875 },
1876 Ok(AgentControlEvent::Durable(_))
1877 | Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {}
1878 Ok(_) => {}
1879 Err(tokio::sync::broadcast::error::RecvError::Closed) => {
1880 bail!("Agent event stream closed before terminal state")
1881 }
1882 },
1883 signal = tokio::signal::ctrl_c() => {
1884 signal.context("listen for Ctrl-C")?;
1885 eprintln!("\nCancelling current Agent Run...");
1886 if let Err(error) = handle.cancel("CLI interrupted by user").await {
1887 tracing::debug!(%error, "Agent Run reached terminal while cancellation was sent");
1888 }
1889 }
1890 line = lines.next_line(), if stdin_open && accept_unsolicited_stdin => {
1891 let line = line.context("read running Agent input")?;
1892 let Some(line) = line else {
1893 stdin_open = false;
1894 continue;
1895 };
1896 let input = line.trim();
1897 if input.is_empty() {
1898 continue;
1899 }
1900 if matches!(input, "/cancel" | "/stop") {
1901 if let Err(error) = handle.cancel("CLI cancellation command").await {
1902 tracing::debug!(%error, "Agent Run reached terminal while cancellation was sent");
1903 }
1904 continue;
1905 }
1906 let ack = handle
1907 .steer_text(input.to_owned())
1908 .await
1909 .context("steer running Agent")?;
1910 match ack.state {
1911 CommandAckState::Accepted { .. } | CommandAckState::Applied { .. } => {
1912 eprintln!("\n[steer accepted]");
1913 }
1914 CommandAckState::Rejected { code, message, .. } => {
1915 eprintln!("\n[steer rejected: {code:?}: {message}]");
1916 }
1917 CommandAckState::Unsupported { feature, .. } => {
1918 eprintln!("\n[steer unsupported: {feature}]");
1919 }
1920 _ => eprintln!("\n[steer acknowledgement pending]"),
1921 }
1922 }
1923 }
1924 };
1925
1926 match &view.state {
1927 AgentRunState::Terminal {
1928 terminal: AgentTerminalState::Delivered { .. },
1929 } => {
1930 let delivery = view
1931 .delivery
1932 .context("Delivered Run omitted its Delivery")?;
1933 print_content(&delivery.final_response.body)?;
1934 }
1935 AgentRunState::Terminal {
1936 terminal: AgentTerminalState::Failed { failure },
1937 } => {
1938 bail!(
1939 "Agent Run failed [{}]{}: {}",
1940 failure.code,
1941 if failure.retryable {
1942 " (retryable)"
1943 } else {
1944 ""
1945 },
1946 failure.message
1947 )
1948 }
1949 AgentRunState::Terminal {
1950 terminal: AgentTerminalState::Incomplete { reason },
1951 } => bail!("Agent Run incomplete: {reason:?}"),
1952 AgentRunState::Terminal {
1953 terminal: AgentTerminalState::Cancelled { reason },
1954 } => bail!("Agent Run cancelled: {reason}"),
1955 state => bail!("Agent Run stopped in unexpected state: {state:?}"),
1956 }
1957 Ok(())
1958}
1959
1960async fn resolve_cli_request(
1961 controller: &Arc<AgentController>,
1962 approval_broker: &Arc<InMemoryHostApprovalBroker>,
1963 run_id: &RunId,
1964 request: PendingRequest,
1965 lines: &mut tokio::io::Lines<BufReader<tokio::io::Stdin>>,
1966) -> anyhow::Result<bool> {
1967 let resolution = match &request.payload {
1968 PendingRequestPayload::Input { prompt, .. } => {
1969 eprintln!("\nInput required:");
1970 for item in prompt {
1971 eprintln!("{}", display_content(item));
1972 }
1973 eprint!("> ");
1974 io::stderr().flush().context("flush input prompt")?;
1975 let answer = tokio::select! {
1976 line = lines.next_line() => line.context("read requested Agent input")?,
1977 signal = tokio::signal::ctrl_c() => {
1978 signal.context("listen for Ctrl-C")?;
1979 eprintln!("\nCancelling current Agent Run...");
1980 if let Err(error) = controller.cancel(run_id, "CLI interrupted during input request").await {
1981 tracing::debug!(%error, "Agent Run reached terminal while cancellation was sent");
1982 }
1983 return Ok(false);
1984 }
1985 };
1986 let Some(answer) = answer else {
1987 controller
1988 .cancel(run_id, "CLI input closed during input request")
1989 .await
1990 .context("cancel Agent after stdin closed")?;
1991 return Ok(false);
1992 };
1993 if answer.trim().is_empty() {
1994 bail!("input response must not be empty")
1995 }
1996 RequestResolution::Input {
1997 content: vec![Content::text(answer)],
1998 }
1999 }
2000 PendingRequestPayload::Approval {
2001 requested_scope,
2002 session_approval_scope,
2003 reason,
2004 ..
2005 } => {
2006 if let Some(grant_ref) = approval_broker
2007 .approve_if_remembered(&request.request_id, approval_expiry_ms())
2008 .context("apply remembered Host approval")?
2009 {
2010 RequestResolution::Approval {
2011 decision: ApprovalDecision::Allow,
2012 grant_ref: Some(grant_ref),
2013 }
2014 } else {
2015 eprintln!("\nApproval required: {reason}");
2016 eprintln!("Effects: {}", requested_scope.join(", "));
2017 if session_approval_scope.is_some() {
2018 eprint!("Approve? [y] once / [a] this session / [N] deny ");
2019 } else {
2020 eprint!("Allow this exact operation? [y/N] ");
2021 }
2022 io::stderr().flush().context("flush approval prompt")?;
2023 let answer = tokio::select! {
2024 line = lines.next_line() => line.context("read approval decision")?,
2025 signal = tokio::signal::ctrl_c() => {
2026 signal.context("listen for Ctrl-C")?;
2027 eprintln!("\nCancelling current Agent Run...");
2028 if let Err(error) = controller.cancel(run_id, "CLI interrupted during approval").await {
2029 tracing::debug!(%error, "Agent Run reached terminal while cancellation was sent");
2030 }
2031 return Ok(false);
2032 }
2033 };
2034 let answer = answer
2035 .as_deref()
2036 .map(str::trim)
2037 .map(str::to_ascii_lowercase)
2038 .unwrap_or_default();
2039 let approve_session = session_approval_scope.is_some()
2040 && matches!(answer.as_str(), "a" | "always" | "session");
2041 let approve_once = matches!(answer.as_str(), "y" | "yes");
2042 if approve_session || approve_once {
2043 let grant_ref = if approve_session {
2044 approval_broker
2045 .approve_for_session(&request.request_id, approval_expiry_ms())
2046 .context("remember Host approval for session")?
2047 } else {
2048 approval_broker
2049 .approve(&request.request_id, approval_expiry_ms())
2050 .context("issue exact Host approval grant")?
2051 };
2052 RequestResolution::Approval {
2053 decision: ApprovalDecision::Allow,
2054 grant_ref: Some(grant_ref),
2055 }
2056 } else {
2057 RequestResolution::Approval {
2058 decision: ApprovalDecision::Deny,
2059 grant_ref: None,
2060 }
2061 }
2062 }
2063 }
2064 PendingRequestPayload::ExternalAction { .. } => {
2065 bail!("CLI does not support external action requests")
2066 }
2067 _ => bail!("CLI does not support this pending request kind"),
2068 };
2069 let command = AgentCommandEnvelope::new(
2070 CommandId::new(unique_id("cli-request", 0)),
2071 run_id.clone(),
2072 Some(request.request_id),
2073 AgentCommand::ResolveRequest {
2074 response: resolution,
2075 },
2076 )
2077 .context("build request resolution command")?;
2078 let ack = controller
2079 .command(command)
2080 .await
2081 .context("resolve Agent request")?;
2082 match ack.state {
2083 CommandAckState::Accepted { .. } | CommandAckState::Applied { .. } => Ok(true),
2084 CommandAckState::Rejected { code, message, .. } => {
2085 bail!("request resolution was rejected ({code:?}): {message}")
2086 }
2087 CommandAckState::Unsupported { feature, .. } => {
2088 bail!("request resolution is unsupported: {feature}")
2089 }
2090 _ => bail!("request resolution returned an unknown acknowledgement state"),
2091 }
2092}
2093
2094fn is_terminal(status: AgentRunStatus) -> bool {
2095 matches!(
2096 status,
2097 AgentRunStatus::Delivered
2098 | AgentRunStatus::Incomplete
2099 | AgentRunStatus::Cancelled
2100 | AgentRunStatus::Failed
2101 )
2102}
2103
2104fn print_content(body: &ContentBody) -> anyhow::Result<()> {
2105 match body {
2106 ContentBody::Inline(serde_json::Value::String(text)) => println!("{text}"),
2107 ContentBody::Inline(value) => println!("{}", serde_json::to_string_pretty(value)?),
2108 ContentBody::Artifact(artifact) => {
2109 println!("{}", serde_json::to_string_pretty(artifact)?)
2110 }
2111 other => println!("{}", serde_json::to_string_pretty(other)?),
2112 }
2113 Ok(())
2114}
2115
2116fn display_content(content: &Content) -> String {
2117 match &content.body {
2118 ContentBody::Inline(serde_json::Value::String(text)) => text.clone(),
2119 body => serde_json::to_string(body).unwrap_or_else(|_| "<unprintable content>".to_owned()),
2120 }
2121}
2122
2123fn unique_id(prefix: &str, sequence: u64) -> String {
2124 let epoch_nanos = SystemTime::now()
2125 .duration_since(UNIX_EPOCH)
2126 .unwrap_or_default()
2127 .as_nanos();
2128 format!("{prefix}-{}-{epoch_nanos}-{sequence}", std::process::id())
2129}
2130
2131fn approval_expiry_ms() -> i64 {
2132 let now_ms = SystemTime::now()
2133 .duration_since(UNIX_EPOCH)
2134 .unwrap_or_default()
2135 .as_millis() as i64;
2136 now_ms.saturating_add(5 * 60 * 1_000)
2137}
2138
2139fn select_entry_mode(
2140 input: Option<String>,
2141 stdin_is_terminal: bool,
2142 stdout_is_terminal: bool,
2143) -> anyhow::Result<EntryMode> {
2144 if let Some(input) = input {
2145 return Ok(EntryMode::HeadlessPrompt(input));
2146 }
2147 if !stdin_is_terminal {
2148 return Ok(EntryMode::HeadlessPipe);
2149 }
2150 if stdout_is_terminal {
2151 return Ok(EntryMode::Tui);
2152 }
2153 bail!(
2154 "interactive mode requires a TTY on stdout; pass a prompt or pipe stdin for Headless mode"
2155 )
2156}
2157
2158#[cfg(test)]
2159#[path = "agent/tool_capability_tests.rs"]
2160mod tool_capability_tests;
2161
2162#[cfg(test)]
2163#[path = "agent/tool_output_budget_tests.rs"]
2164mod tool_output_budget_tests;
2165
2166#[cfg(test)]
2167mod entry_mode_tests {
2168 use std::path::PathBuf;
2169
2170 use super::{select_entry_mode, unique_id, CliWorkspaceSet, EntryMode, InputMode};
2171
2172 #[test]
2173 fn input_mode_uses_the_reply_channel_not_headless_output() {
2174 let prompt = EntryMode::HeadlessPrompt("inspect the project".to_owned());
2175 assert!(!InputMode::Auto.resolve(&prompt, false).unwrap());
2176 assert!(InputMode::Auto.resolve(&prompt, true).unwrap());
2177 assert!(InputMode::Auto.resolve(&EntryMode::Tui, true).unwrap());
2178 assert!(InputMode::Interactive.resolve(&prompt, false).unwrap());
2179 assert!(!InputMode::None.resolve(&EntryMode::Tui, true).unwrap());
2180 assert!(!InputMode::Auto
2181 .resolve(&EntryMode::HeadlessPipe, false)
2182 .unwrap());
2183 assert!(InputMode::Interactive
2184 .resolve(&EntryMode::HeadlessPipe, false)
2185 .is_err());
2186 }
2187
2188 #[test]
2189 fn model_profile_sampling_is_validated_before_connecting() {
2190 let backend = serde_json::from_value(serde_json::json!({
2191 "name": "local", "kind": "openai", "endpoint": "http://127.0.0.1:1/v1",
2192 "config": {"auth": "none"},
2193 }))
2194 .unwrap();
2195 for sampling in [
2196 serde_json::json!({"top_k": "20"}),
2197 serde_json::json!({"topk": 20}),
2198 serde_json::json!({"repetition_penalty": 0}),
2199 ] {
2200 let profile = serde_json::from_value(serde_json::json!({
2201 "name": "local", "backend": "local", "model": "local-model",
2202 "config": {"sampling": sampling},
2203 }))
2204 .unwrap();
2205 let error =
2206 super::build_model_backend(&backend, "local-model", 0.6, Some(&profile), 8, None)
2207 .err()
2208 .expect("invalid model sampling must fail before HTTP");
2209 assert!(format!("{error:#}").contains("sampling"));
2210 }
2211 }
2212
2213 #[test]
2214 fn model_profile_tool_result_format_defaults_to_text_and_is_strict() {
2215 let backend = serde_json::from_value(serde_json::json!({
2216 "name": "local", "kind": "openai", "endpoint": "http://127.0.0.1:1/v1",
2217 "config": {"auth": "none"},
2218 }))
2219 .unwrap();
2220 let profile = |format| {
2221 serde_json::from_value(serde_json::json!({
2222 "name": "local", "backend": "local", "model": "local-model",
2223 "config": {"tool_result_format": format},
2224 }))
2225 .unwrap()
2226 };
2227 for format in [
2228 serde_json::json!("unsupported"),
2229 serde_json::json!(true),
2230 serde_json::Value::Null,
2231 ] {
2232 let error = super::build_model_backend(
2233 &backend,
2234 "local-model",
2235 0.6,
2236 Some(&profile(format)),
2237 8,
2238 None,
2239 )
2240 .err()
2241 .expect("invalid result format must fail before HTTP");
2242 assert!(format!("{error:#}").contains("tool_result_format"));
2243 }
2244 let (_, json_meter) = super::build_model_backend(
2245 &backend,
2246 "local-model",
2247 0.6,
2248 Some(&profile(serde_json::json!("json"))),
2249 8,
2250 None,
2251 )
2252 .unwrap();
2253 let (_, yaml_meter) = super::build_model_backend(
2254 &backend,
2255 "local-model",
2256 0.6,
2257 Some(&profile(serde_json::json!("yaml"))),
2258 8,
2259 None,
2260 )
2261 .unwrap();
2262 assert_ne!(json_meter.meter_descriptor(), yaml_meter.meter_descriptor());
2263 let (_, text_meter) = super::build_model_backend(
2264 &backend,
2265 "local-model",
2266 0.6,
2267 Some(&profile(serde_json::json!("text"))),
2268 8,
2269 None,
2270 )
2271 .unwrap();
2272 assert_ne!(text_meter.meter_descriptor(), yaml_meter.meter_descriptor());
2273 assert_ne!(text_meter.meter_descriptor(), json_meter.meter_descriptor());
2274 let (_, default_meter) =
2275 super::build_model_backend(&backend, "local-model", 0.6, None, 8, None).unwrap();
2276 assert_eq!(
2277 default_meter.meter_descriptor(),
2278 text_meter.meter_descriptor()
2279 );
2280 let mut omitted_format = profile(serde_json::json!("json"));
2281 omitted_format
2282 .config
2283 .as_object_mut()
2284 .unwrap()
2285 .remove("tool_result_format");
2286 let (_, omitted_meter) = super::build_model_backend(
2287 &backend,
2288 "local-model",
2289 0.6,
2290 Some(&omitted_format),
2291 8,
2292 None,
2293 )
2294 .unwrap();
2295 assert_eq!(
2296 omitted_meter.meter_descriptor(),
2297 text_meter.meter_descriptor()
2298 );
2299 }
2300
2301 #[cfg(unix)]
2302 #[test]
2303 fn disabling_sandboxed_execution_requires_a_host_execution_ceiling() {
2304 let mut config = orchestral_core::config::OrchestralConfig::default();
2305 config.tools.exec.enabled = true;
2306 config.tools.exec.sandboxed_execution_enabled = false;
2307 let error = super::configured_exec_host(&config)
2308 .err()
2309 .expect("invalid Host policy");
2310 assert!(error.to_string().contains("allow_host_execution=true"));
2311 config.tools.exec.allow_host_execution = true;
2312 config.tools.exec.shell = Some("/bin/sh".to_owned());
2313 assert!(super::configured_exec_host(&config).unwrap().is_some());
2314 }
2315
2316 #[cfg(unix)]
2317 #[test]
2318 fn command_temp_root_is_stable_external_and_short_enough_for_unix_sockets() {
2319 let workspace = std::env::temp_dir().join(unique_id("temp-root-workspace", 0));
2320 std::fs::create_dir(&workspace).unwrap();
2321 let workspace = std::fs::canonicalize(workspace).unwrap();
2322 let roots = std::collections::BTreeSet::from([workspace.to_string_lossy().into_owned()]);
2323 let path = super::command_runtime_temp_root(&roots).unwrap();
2324 assert_eq!(path, super::command_runtime_temp_root(&roots).unwrap());
2325 assert!(!path.starts_with(&workspace));
2326 let manager =
2327 orchestral_runtime::ProcessSupervisor::new_with_runtime_temp_root(1024, &path).unwrap();
2328 let child = manager
2330 .runtime_temp_root()
2331 .join("a".repeat(32))
2332 .join(".tmpabcdef");
2333 std::fs::create_dir_all(&child).unwrap();
2334 let listener = std::os::unix::net::UnixListener::bind(child.join("control.sock"))
2335 .expect("Host prefix must leave room for a Run and child tool's Unix socket");
2336 drop(listener);
2337 drop(manager);
2338 std::fs::remove_dir_all(path).unwrap();
2339 std::fs::remove_dir(workspace).unwrap();
2340 }
2341
2342 #[test]
2343 fn explicit_prompt_is_always_headless() {
2344 assert_eq!(
2345 select_entry_mode(Some("fix it".to_owned()), true, true).unwrap(),
2346 EntryMode::HeadlessPrompt("fix it".to_owned())
2347 );
2348 assert_eq!(
2349 select_entry_mode(Some("fix it".to_owned()), false, false).unwrap(),
2350 EntryMode::HeadlessPrompt("fix it".to_owned())
2351 );
2352 }
2353
2354 #[test]
2355 fn pipe_is_headless_and_interactive_ttys_use_tui() {
2356 assert_eq!(
2357 select_entry_mode(None, false, true).unwrap(),
2358 EntryMode::HeadlessPipe
2359 );
2360 assert_eq!(select_entry_mode(None, true, true).unwrap(), EntryMode::Tui);
2361 }
2362
2363 #[test]
2364 fn interactive_stdin_without_terminal_output_is_rejected() {
2365 let error = select_entry_mode(None, true, false).unwrap_err();
2366 assert!(error.to_string().contains("requires a TTY on stdout"));
2367 }
2368
2369 #[test]
2370 fn additional_workspace_paths_are_resolved_from_the_primary_workspace() {
2371 let root = std::env::temp_dir().join(unique_id("orchestral-workspace-set-test", 0));
2372 let primary = root.join("primary");
2373 let additional = primary.join("../shared");
2374 std::fs::create_dir_all(&primary).unwrap();
2375 std::fs::create_dir_all(&additional).unwrap();
2376
2377 let workspaces =
2378 CliWorkspaceSet::resolve(Some(&primary), &[PathBuf::from("../shared")]).unwrap();
2379 assert_eq!(workspaces.primary, std::fs::canonicalize(&primary).unwrap());
2380 assert_eq!(
2381 workspaces.additional,
2382 [std::fs::canonicalize(&additional).unwrap()]
2383 );
2384
2385 std::fs::remove_dir_all(root).unwrap();
2386 }
2387}