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