Skip to main content

orchestral_cli/
agent.rs

1//! Minimal command-line composition root for the provider-neutral Generic Agent.
2
3use 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/// Availability of follow-up user input for a local CLI run.
98#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, clap::ValueEnum)]
99pub enum InputMode {
100    /// Enable follow-up input only when stdin is a terminal.
101    #[default]
102    Auto,
103    /// Allow replies on stdin; a piped initial prompt cannot share that stream.
104    Interactive,
105    /// Do not offer model-requested user input.
106    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        // Freeze the model schema at registration from the Host workspace set.
166        // The executor already defaults to the primary root when this is omitted.
167        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    // None denotes a Host with its own input channel (for example HTTP serve).
248    // Local runs retain their resolved capability across TUI reconfiguration.
249    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    // Session identity is resolved from the registered invoking Run, never
473    // from model arguments or a filesystem path supplied by the model.
474    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    // A per-result allowance, not a tokenizer estimate or a guarantee for an
728    // entire conversation. Leave room for the request, history and other tools.
729    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            // Explicit `mcp add`/Host configuration trusts the transport.
846            // Per-call approval is derived from standard MCP Tool annotations.
847            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            // The Host ceiling admits both sandboxed local capabilities and
881            // non-process transports. Each tool lane decides whether its own
882            // sandbox is mandatory.
883            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    // Tool restrictions are capability-local. MCP transport programs,
936    // credentials, environment names, and network targets never become
937    // ambient authority for generic command execution.
938    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    // Leave room for tools that create Unix sockets below TMPDIR. The macOS
1150    // per-user OS temp prefix alone can consume much of sockaddr_un.sun_path.
1151    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            // Version-managed runtimes may load adjacent libraries, but never
1303            // widen a PATH entry to the manager's entire user directory (for
1304            // example ~/.cargo, which can contain credentials).
1305            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
1350/// MCP and Skill remain distinct resources. Only explicit Host MCP transports
1351/// are composed here; discovered methods publish as guarded Tools.
1352fn 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            // Match Codex's proven default: remote MCP operations routinely
1466            // include search, authentication, and service fan-out that exceed
1467            // a generic short Tool deadline.
1468            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            // A recovered Run can finish while input is offered. Only an
1808            // explicit rejection permits a new Run; an uncertain command
1809            // result must never cause duplicate input.
1810        }
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    // Accepted input may precede the durable RequestResolved projection. Treat
1835    // the initial resume answer like later answers, instead of prompting again
1836    // (and cancelling on closed stdin) while that same command is being applied.
1837    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        // Reserve one opaque Run component and one child tool's temp directory.
2350        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}