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