From f4d92f21d411a39f601542c5b619bf5f18261bdf Mon Sep 17 00:00:00 2001 From: Copilot CLI Date: Sun, 29 Mar 2026 22:20:13 +0000 Subject: [PATCH] feat: add terminal-shell engineer foundation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Specs/ProductArchitecture.md | 5 +- ...figure-bootstrap-and-inspect-reflection.md | 27 +- docs/index.md | 8 +- docs/reference/runtime-contracts.md | 12 +- .../tutorials/run-your-first-local-session.md | 23 +- src/base_types.rs | 125 +++++++++ src/bin/simard_operator_probe.rs | 60 +++++ src/bootstrap.rs | 9 +- src/identity.rs | 1 + src/lib.rs | 3 +- src/reflection.rs | 2 + src/runtime.rs | 14 + src/terminal_session.rs | 241 ++++++++++++++++++ tests/bootstrap.rs | 76 +++++- tests/contracts.rs | 14 + tests/gadugi/simard-cli-smoke.yaml | 8 + tests/gadugi/terminal-session.sh | 24 ++ 17 files changed, 635 insertions(+), 17 deletions(-) create mode 100644 src/terminal_session.rs create mode 100755 tests/gadugi/terminal-session.sh diff --git a/Specs/ProductArchitecture.md b/Specs/ProductArchitecture.md index caa7451ee..d5501dd0a 100644 --- a/Specs/ProductArchitecture.md +++ b/Specs/ProductArchitecture.md @@ -53,7 +53,7 @@ For delivery purposes, v1 should be interpreted narrowly: - one primary engineer loop - one default local single-process deployment path, plus an explicit loopback multi-process operator path for supported base types -- three builtin manifest-advertised base-type selections in the local scaffold: `local-harness`, `rusty-clawd`, and `copilot-sdk`, with `rusty-clawd` now behaving as a distinct session backend and `copilot-sdk` remaining an explicit alias of the local harness implementation +- four builtin manifest-advertised base-type selections in the local scaffold: `local-harness`, `terminal-shell`, `rusty-clawd`, and `copilot-sdk`, with `terminal-shell` now providing a local PTY-backed shell path for the engineer identity, `rusty-clawd` behaving as a distinct session backend, and `copilot-sdk` remaining an explicit alias of the local harness implementation - one durable memory path - one small benchmark set @@ -68,7 +68,7 @@ The following are hard constraints for the first implementation, not deferred as - **Dependency injection from the outset**: runtime composition must happen through explicit ports, traits, and typed configuration rather than hidden globals or direct construction buried inside the core loop. - **Distributed-readiness from the outset**: runtime, session, memory, and reflection contracts must not assume in-process execution even when the first deployment path is single-process. -- **Multiple base types from the outset**: identity manifests and runtime selection logic must support multiple base types immediately. In the current v1 scaffold, `local-harness`, `rusty-clawd`, and `copilot-sdk` are all selectable builtin base types. `rusty-clawd` is now a distinct session backend, while `copilot-sdk` remains an explicit alias of the local single-process harness implementation. +- **Multiple base types from the outset**: identity manifests and runtime selection logic must support multiple base types immediately. In the current v1 scaffold, `local-harness`, `terminal-shell`, `rusty-clawd`, and `copilot-sdk` are all selectable builtin base types for the engineer identity. `terminal-shell` is a local PTY-backed shell path, `rusty-clawd` is a distinct session backend, and `copilot-sdk` remains an explicit alias of the local single-process harness implementation. - **Visible failures from the outset**: unsupported capabilities, missing prompt assets, invalid lifecycle transitions, and unsupported topologies must fail explicitly through typed errors instead of silent fallbacks. ## Non-Goals @@ -303,6 +303,7 @@ Candidate base types include: The current Simard scaffold already publishes builtin manifest-facing base-type identifiers for: - `local-harness` +- `terminal-shell` - `rusty-clawd` - `copilot-sdk` diff --git a/docs/howto/configure-bootstrap-and-inspect-reflection.md b/docs/howto/configure-bootstrap-and-inspect-reflection.md index a8593d740..45f12b559 100644 --- a/docs/howto/configure-bootstrap-and-inspect-reflection.md +++ b/docs/howto/configure-bootstrap-and-inspect-reflection.md @@ -28,7 +28,7 @@ Use this guide when you need to answer two questions: Provide the prompt root, objective, and state root yourself. -For the builtin identities in this repo, the current scaffold accepts `local-harness`, `rusty-clawd`, or `copilot-sdk` as explicit base-type choices. `rusty-clawd` is a distinct session backend, while `copilot-sdk` remains an explicit alias of `local-harness`. The bootstrap path now injects either the in-process runtime services for `single-process` or the loopback mesh services for `multi-process`, so unsupported topology/base-type pairs fail explicitly instead of being rewritten. +For the builtin identities in this repo, the current scaffold accepts `local-harness`, `rusty-clawd`, or `copilot-sdk` as explicit base-type choices everywhere, and `simard-engineer` additionally accepts `terminal-shell` for a real local PTY-backed shell session. `rusty-clawd` is a distinct session backend, `terminal-shell` is intentionally local-only, and `copilot-sdk` remains an explicit alias of `local-harness`. The bootstrap path now injects either the in-process runtime services for `single-process` or the loopback mesh services for `multi-process`, so unsupported topology/base-type pairs fail explicitly instead of being rewritten. ```bash SIMARD_PROMPT_ROOT="$PWD/prompt_assets" \ @@ -111,6 +111,8 @@ Builtin defaults are startup choices. They are not recovery behavior. - `agent_program_backend` - `handoff_backend` - `adapter_backend` +- `adapter_capabilities` +- `adapter_supported_topologies` - `topology_backend` - `transport_backend` - `supervisor_backend` @@ -145,7 +147,7 @@ assert_eq!(snapshot.memory_backend.identity, "memory::json-file-store"); assert_eq!(snapshot.evidence_backend.identity, "evidence::json-file-store"); ``` -If you launched with `SIMARD_BASE_TYPE="copilot-sdk"`, `snapshot.selected_base_type` still shows the alias you chose while `snapshot.adapter_backend.identity` remains `local-harness`. If you launched with `SIMARD_BASE_TYPE="rusty-clawd"`, reflection truthfully reports `snapshot.adapter_backend.identity == "rusty-clawd::session-backend"`. Composite identities also surface `snapshot.identity_components` so operator tooling can see which roles were assembled. +If you launched with `SIMARD_BASE_TYPE="copilot-sdk"`, `snapshot.selected_base_type` still shows the alias you chose while `snapshot.adapter_backend.identity` remains `local-harness`. If you launched with `SIMARD_BASE_TYPE="rusty-clawd"`, reflection truthfully reports `snapshot.adapter_backend.identity == "rusty-clawd::session-backend"`. If you launch the engineer identity with `SIMARD_BASE_TYPE="terminal-shell"`, reflection reports `snapshot.adapter_backend.identity == "terminal-shell::local-pty"`, `snapshot.adapter_capabilities` includes `terminal-session`, and `snapshot.adapter_supported_topologies == ["single-process"]`. Composite identities also surface `snapshot.identity_components` so operator tooling can see which roles were assembled. The same redaction rule applies to persisted session text: scratch memory, session summaries, and reflection summaries record `objective-metadata(...)` instead of the raw `SIMARD_OBJECTIVE` string. @@ -176,6 +178,27 @@ Look for: This is the current honest v1 behavior: meeting mode captures structured planning output and writes concise decision memory, but it does not mutate code. +## 6. Exercise the terminal-backed engineer path + +Use the operator probe when you want to validate the new terminal-backed engineer substrate directly: + +```bash +cargo run --quiet --bin simard_operator_probe -- \ + terminal-run single-process \ + $'working-directory: .\ncommand: pwd\ncommand: printf "terminal-foundation-ok\\n"' +``` + +Look for: + +- `Probe mode: terminal-run` +- `Selected base type: terminal-shell` +- `Adapter implementation: terminal-shell::local-pty` +- `Adapter capabilities: ... terminal-session` +- `Terminal evidence: terminal-command-count=2` +- a transcript preview containing `terminal-foundation-ok` + +This is the honest v1 terminal slice: Simard can drive a real local PTY-backed shell session through the runtime, but it does not claim remote hosts, Azlin orchestration, or distributed terminal control yet. + ## 4. Validate stopped-state behavior Stopping the runtime is already observable. diff --git a/docs/index.md b/docs/index.md index 9c91862bc..3d28aad3c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -27,19 +27,19 @@ Today Simard provides: - explicit bootstrap configuration, with `builtin-defaults` available only through opt-in startup mode - a durable local state root selected through `SIMARD_STATE_ROOT` in `explicit-config` or defaulted through `builtin-defaults` - explicit base-type and topology selection at bootstrap, with opt-in defaults only in `builtin-defaults` -- builtin manifest-advertised base types selectable at startup today: `local-harness`, `rusty-clawd`, and `copilot-sdk`, with `rusty-clawd` wired as a distinct session backend and `copilot-sdk` still aliased to the local harness implementation +- builtin manifest-advertised base types selectable at startup today: `local-harness`, `terminal-shell`, `rusty-clawd`, and `copilot-sdk`, with `terminal-shell` wired as a real local PTY-backed shell session for `simard-engineer`, `rusty-clawd` wired as a distinct session backend, and `copilot-sdk` still aliased to the local harness implementation - builtin identities selectable at startup today: `simard-engineer`, `simard-meeting`, `simard-gym`, and the composite `simard-composite-engineer` - a facilitator-backed `simard-meeting` identity that captures structured decisions, risks, next steps, and open questions without mutating code -- `single-process` for all builtin base types plus loopback `multi-process` execution for `rusty-clawd`, with unsupported pairs failing explicitly +- `single-process` for all builtin base types plus loopback `multi-process` execution for `rusty-clawd`, with `terminal-shell` intentionally limited to local single-process runs and unsupported pairs failing explicitly - a starter benchmark gym suite that exercises all current builtin base-type selections plus a composite identity session through `cargo run --quiet --bin simard-gym -- run-suite starter` - benchmark artifacts written under `target/simard-gym/`, including per-scenario JSON and text summaries, a dedicated review artifact, and a suite summary - `ManifestContract { entrypoint, composition, precedence, provenance, freshness }` -- `ReflectionSnapshot { manifest_contract, runtime_node, mailbox_address, agent_program_backend, adapter_backend, topology_backend, transport_backend, supervisor_backend, memory_backend, evidence_backend }` +- `ReflectionSnapshot { manifest_contract, runtime_node, mailbox_address, agent_program_backend, adapter_backend, adapter_capabilities, adapter_supported_topologies, topology_backend, transport_backend, supervisor_backend, memory_backend, evidence_backend }` - truthful memory and evidence backend descriptors - file-backed memory, evidence, and handoff stores on the bootstrap path, with persisted local state under the configured state root - durable meeting decision records persisted under the configured state root so later sessions can inspect concise planning outcomes - offline review artifacts and concise decision records persisted under the configured state root when the operator runs the review probe -- truthful runtime service metadata from the runtime-selected wiring, including the injected agent program, handoff store, and the canonical backend identities behind each selected base type +- truthful runtime service metadata from the runtime-selected wiring, including the injected agent program, handoff store, the canonical backend identities behind each selected base type, and adapter capability/topology limits surfaced directly in reflection - persisted scratch, summary, and reflection text that records objective metadata instead of raw objective text - handoff snapshots that preserve runtime/session continuity while redacting the persisted session objective down to objective metadata - canonical session IDs shaped as `session-`, with validation at parsing boundaries diff --git a/docs/reference/runtime-contracts.md b/docs/reference/runtime-contracts.md index 7a01e6250..72627638e 100644 --- a/docs/reference/runtime-contracts.md +++ b/docs/reference/runtime-contracts.md @@ -36,7 +36,7 @@ The stable contract in this repository is the bootstrap/runtime and benchmark-gy ## Meeting-mode operator flow -The shipped operator probe also supports a meeting-specific path: +The shipped operator probe also supports meeting-specific and terminal-session-specific paths: - `cargo run --quiet --bin simard_operator_probe -- meeting-run ` - `cargo run --quiet --bin simard_operator_probe -- review-run ` @@ -86,6 +86,7 @@ The shipped benchmark CLI currently supports: The starter suite is intentionally small and exercises: - `local-harness` +- `terminal-shell` - `copilot-sdk` - `rusty-clawd` - the dedicated `simard-gym` identity @@ -109,11 +110,12 @@ Artifacts are written under `target/simard-gym/` as JSON and text reports plus a ### Current builtin base-type registrations -The builtin identities currently advertised by the loader are `simard-engineer`, `simard-meeting`, `simard-gym`, and the composite `simard-composite-engineer`. Their common builtin base-type registrations are: +The builtin identities currently advertised by the loader are `simard-engineer`, `simard-meeting`, `simard-gym`, and the composite `simard-composite-engineer`. All of them accept `local-harness`, `rusty-clawd`, and `copilot-sdk`; `simard-engineer` additionally accepts `terminal-shell` for the local terminal-backed path: | Base type selection | Current session backend implementation | Supported topologies in this scaffold | | --- | --- | --- | | `local-harness` | `local-harness` single-process local process harness session backend | `single-process` | +| `terminal-shell` | `terminal-shell::local-pty` real local PTY-backed shell session backend (`simard-engineer` only) | `single-process` | | `rusty-clawd` | `rusty-clawd::session-backend` real session backend | `single-process`, `multi-process` | | `copilot-sdk` | `local-harness` single-process local process harness session backend (alias) | `single-process` | @@ -124,8 +126,8 @@ Notes: - for `multi-process` and `distributed`, bootstrap injects `topology::loopback-mesh`, `transport::loopback-mailbox`, and `supervisor::coordinated` - unsupported topology/base-type pairs still fail explicitly; for example, `local-harness + multi-process` returns `UnsupportedTopology` - if a future identity advertises a base type without a registered factory, runtime composition still fails explicitly with `AdapterNotRegistered` -- the descriptors remain truthful: `selected_base_type` preserves the explicit choice, while `adapter_backend.identity` exposes the actual backend (`rusty-clawd::session-backend` for `rusty-clawd`, `local-harness` for the current `copilot-sdk` alias) -- `runtime_node`, `mailbox_address`, `topology_backend`, `transport_backend`, `supervisor_backend`, and `handoff_backend` expose the actual runtime assembly rather than inferred labels +- the descriptors remain truthful: `selected_base_type` preserves the explicit choice, while `adapter_backend.identity` exposes the actual backend (`terminal-shell::local-pty` for `terminal-shell`, `rusty-clawd::session-backend` for `rusty-clawd`, `local-harness` for the current `copilot-sdk` alias) +- `runtime_node`, `mailbox_address`, `adapter_capabilities`, `adapter_supported_topologies`, `topology_backend`, `transport_backend`, `supervisor_backend`, and `handoff_backend` expose the actual runtime assembly rather than inferred labels - `MemoryPolicy.allow_project_writes=true` is rejected explicitly in v1 rather than being ignored ### Bootstrap modes @@ -339,6 +341,8 @@ pub struct ReflectionSnapshot { pub agent_program_backend: BackendDescriptor, pub handoff_backend: BackendDescriptor, pub adapter_backend: BackendDescriptor, + pub adapter_capabilities: Vec, + pub adapter_supported_topologies: Vec, pub topology_backend: BackendDescriptor, pub transport_backend: BackendDescriptor, pub supervisor_backend: BackendDescriptor, diff --git a/docs/tutorials/run-your-first-local-session.md b/docs/tutorials/run-your-first-local-session.md index 6b63898a4..6bd4af584 100644 --- a/docs/tutorials/run-your-first-local-session.md +++ b/docs/tutorials/run-your-first-local-session.md @@ -32,7 +32,7 @@ This tutorial follows the runtime path that exists in the repository today. From the repository root, start Simard with a real prompt asset directory, an explicit objective, and an explicit durable state root. -For the builtin identities in this repo, you can currently choose `local-harness`, `rusty-clawd`, or `copilot-sdk` here. `rusty-clawd` is a distinct backend, while `copilot-sdk` remains an explicit alias of the local harness implementation. The default bootstrap path still opts into `single-process`, but the runtime can now inject a loopback `multi-process` topology when you request a supported pairing such as `rusty-clawd + multi-process`. +For the builtin identities in this repo, you can currently choose `local-harness`, `rusty-clawd`, or `copilot-sdk` everywhere, and `simard-engineer` additionally accepts `terminal-shell` for a real local PTY-backed shell session. `rusty-clawd` is a distinct backend, `terminal-shell` is intentionally local-only, and `copilot-sdk` remains an explicit alias of the local harness implementation. The default bootstrap path still opts into `single-process`, but the runtime can now inject a loopback `multi-process` topology when you request a supported pairing such as `rusty-clawd + multi-process`. ```bash SIMARD_PROMPT_ROOT="$PWD/prompt_assets" \ @@ -83,6 +83,27 @@ Adapter implementation: local-harness **Checkpoint**: the runtime contract is explicit. `copilot-sdk` is selectable now, but its underlying implementation still stays `local-harness`. Simard preserves the selected base type without pretending the alias is already a distinct backend integration. +### Variation: exercise the terminal-backed engineer path + +Use the shipped operator probe to drive a real local PTY-backed shell session through the runtime: + +```bash +cargo run --quiet --bin simard_operator_probe -- \ + terminal-run single-process \ + $'working-directory: .\ncommand: pwd\ncommand: printf "terminal-foundation-ok\\n"' +``` + +Look for these lines: + +```text +Probe mode: terminal-run +Selected base type: terminal-shell +Adapter implementation: terminal-shell::local-pty +Terminal evidence: terminal-command-count=2 +``` + +**Checkpoint**: this path is no longer synthetic. The runtime is actually allocating a local PTY-backed shell session and preserving a transcript preview in evidence, while still honestly limiting the feature to local single-process execution. + ## Step 3: Exercise a composite identity and loopback multi-process runtime Use the shipped operator probe to validate the broader runtime seams like an operator would. diff --git a/src/base_types.rs b/src/base_types.rs index aa0b51cda..09a0c66d1 100644 --- a/src/base_types.rs +++ b/src/base_types.rs @@ -10,6 +10,7 @@ use crate::prompt_assets::PromptAssetRef; use crate::runtime::{RuntimeAddress, RuntimeNodeId, RuntimeTopology}; use crate::sanitization::objective_metadata; use crate::session::SessionId; +use crate::terminal_session::execute_terminal_turn; #[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)] pub struct BaseTypeId(String); @@ -43,6 +44,7 @@ pub enum BaseTypeCapability { Memory, Evidence, Reflection, + TerminalSession, } impl Display for BaseTypeCapability { @@ -53,6 +55,7 @@ impl Display for BaseTypeCapability { Self::Memory => "memory", Self::Evidence => "evidence", Self::Reflection => "reflection", + Self::TerminalSession => "terminal-session", }; f.write_str(label) } @@ -357,6 +360,128 @@ impl BaseTypeSession for RustyClawdSession { } } +#[derive(Debug)] +pub struct TerminalShellAdapter { + descriptor: BaseTypeDescriptor, +} + +impl TerminalShellAdapter { + pub fn registered(id: impl Into) -> SimardResult { + let id = BaseTypeId::new(id); + Ok(Self { + descriptor: BaseTypeDescriptor { + id, + backend: BackendDescriptor::for_runtime_type::( + "terminal-shell::local-pty", + "registered-base-type:terminal-shell", + Freshness::now()?, + ), + capabilities: capability_set([ + BaseTypeCapability::PromptAssets, + BaseTypeCapability::SessionLifecycle, + BaseTypeCapability::Memory, + BaseTypeCapability::Evidence, + BaseTypeCapability::Reflection, + BaseTypeCapability::TerminalSession, + ]), + supported_topologies: [RuntimeTopology::SingleProcess].into_iter().collect(), + }, + }) + } +} + +impl BaseTypeFactory for TerminalShellAdapter { + fn descriptor(&self) -> &BaseTypeDescriptor { + &self.descriptor + } + + fn open_session( + &self, + request: BaseTypeSessionRequest, + ) -> SimardResult> { + if !self.descriptor.supports_topology(request.topology) { + return Err(SimardError::UnsupportedTopology { + base_type: self.descriptor.id.to_string(), + topology: request.topology, + }); + } + + Ok(Box::new(TerminalShellSession { + descriptor: self.descriptor.clone(), + request, + is_open: false, + is_closed: false, + })) + } +} + +#[derive(Debug)] +struct TerminalShellSession { + descriptor: BaseTypeDescriptor, + request: BaseTypeSessionRequest, + is_open: bool, + is_closed: bool, +} + +impl TerminalShellSession { + fn ensure_can(&self, action: &str) -> SimardResult<()> { + if self.is_closed { + return Err(SimardError::InvalidBaseTypeSessionState { + base_type: self.descriptor.id.to_string(), + action: action.to_string(), + reason: "session is already closed".to_string(), + }); + } + + Ok(()) + } +} + +impl BaseTypeSession for TerminalShellSession { + fn descriptor(&self) -> &BaseTypeDescriptor { + &self.descriptor + } + + fn open(&mut self) -> SimardResult<()> { + self.ensure_can("open")?; + if self.is_open { + return Err(SimardError::InvalidBaseTypeSessionState { + base_type: self.descriptor.id.to_string(), + action: "open".to_string(), + reason: "session is already open".to_string(), + }); + } + self.is_open = true; + Ok(()) + } + + fn run_turn(&mut self, input: BaseTypeTurnInput) -> SimardResult { + self.ensure_can("run_turn")?; + if !self.is_open { + return Err(SimardError::InvalidBaseTypeSessionState { + base_type: self.descriptor.id.to_string(), + action: "run_turn".to_string(), + reason: "session must be opened before turns can run".to_string(), + }); + } + + execute_terminal_turn(&self.descriptor, &self.request, &input) + } + + fn close(&mut self) -> SimardResult<()> { + self.ensure_can("close")?; + if !self.is_open { + return Err(SimardError::InvalidBaseTypeSessionState { + base_type: self.descriptor.id.to_string(), + action: "close".to_string(), + reason: "session was never opened".to_string(), + }); + } + self.is_closed = true; + Ok(()) + } +} + #[derive(Debug)] struct LocalProcessHarnessSession { descriptor: BaseTypeDescriptor, diff --git a/src/bin/simard_operator_probe.rs b/src/bin/simard_operator_probe.rs index a7553b780..e7a9c2339 100644 --- a/src/bin/simard_operator_probe.rs +++ b/src/bin/simard_operator_probe.rs @@ -32,6 +32,11 @@ fn main() -> Result<(), Box> { let objective = args.next().ok_or("expected objective")?; run_meeting_probe(&base_type, &topology, &objective)?; } + "terminal-run" => { + let topology = args.next().ok_or("expected topology")?; + let objective = args.next().ok_or("expected objective")?; + run_terminal_probe(&topology, &objective)?; + } "review-run" => { let base_type = args.next().ok_or("expected base type")?; let topology = args.next().ok_or("expected topology")?; @@ -236,6 +241,61 @@ fn run_meeting_probe( Ok(()) } +fn run_terminal_probe(topology: &str, objective: &str) -> Result<(), Box> { + let identity = "simard-engineer"; + let base_type = "terminal-shell"; + let config = BootstrapConfig::resolve(BootstrapInputs { + prompt_root: Some(prompt_root()), + objective: Some(objective.to_string()), + state_root: Some(state_root(identity, base_type, topology, "terminal-run")), + identity: Some(identity.to_string()), + base_type: Some(base_type.to_string()), + topology: Some(topology.to_string()), + ..BootstrapInputs::default() + })?; + + let execution = simard::run_local_session(&config)?; + let exported = latest_local_handoff(&config)?.ok_or("expected durable terminal handoff")?; + let terminal_evidence = exported + .evidence_records + .iter() + .filter(|record| { + record.detail.starts_with("shell=") + || record.detail.starts_with("terminal-") + || record.detail.starts_with("backend-implementation=") + }) + .map(|record| record.detail.clone()) + .collect::>(); + + println!("Probe mode: terminal-run"); + println!("Identity: {}", execution.snapshot.identity_name); + println!( + "Selected base type: {}", + execution.snapshot.selected_base_type + ); + println!("Topology: {}", execution.snapshot.topology); + println!( + "Adapter implementation: {}", + execution.snapshot.adapter_backend.identity + ); + println!( + "Adapter capabilities: {}", + execution.snapshot.adapter_capabilities.join(", ") + ); + println!("State root: {}", config.state_root_path().display()); + println!("Session phase: {}", execution.outcome.session.phase); + println!("Terminal evidence lines: {}", terminal_evidence.len()); + for detail in terminal_evidence { + println!("Terminal evidence: {detail}"); + } + println!("Execution summary: {}", execution.outcome.execution_summary); + println!( + "Reflection summary: {}", + execution.outcome.reflection.summary + ); + Ok(()) +} + fn run_review_probe( base_type: &str, topology: &str, diff --git a/src/bootstrap.rs b/src/bootstrap.rs index 4189d42a9..c5b5adffc 100644 --- a/src/bootstrap.rs +++ b/src/bootstrap.rs @@ -5,7 +5,9 @@ use std::path::PathBuf; use std::sync::Arc; use crate::agent_program::{AgentProgram, MeetingFacilitatorProgram, ObjectiveRelayProgram}; -use crate::base_types::{BaseTypeId, LocalProcessHarnessAdapter, RustyClawdAdapter}; +use crate::base_types::{ + BaseTypeId, LocalProcessHarnessAdapter, RustyClawdAdapter, TerminalShellAdapter, +}; use crate::error::{SimardError, SimardResult}; use crate::evidence::{EvidenceStore, FileBackedEvidenceStore}; use crate::handoff::{FileBackedHandoffStore, RuntimeHandoffSnapshot, RuntimeHandoffStore}; @@ -27,6 +29,7 @@ const DEFAULT_IDENTITY: &str = "simard-engineer"; const DEFAULT_OBJECTIVE: &str = "bootstrap the Simard engineer loop"; const DEFAULT_STATE_ROOT: &str = "target/simard-state"; const LOCAL_BASE_TYPE: &str = "local-harness"; +const TERMINAL_SHELL_BASE_TYPE: &str = "terminal-shell"; const RUSTY_CLAWD_BASE_TYPE: &str = "rusty-clawd"; const COPILOT_SDK_BASE_TYPE: &str = "copilot-sdk"; @@ -502,6 +505,10 @@ fn register_builtin_base_type( )?); Ok(()) } + TERMINAL_SHELL_BASE_TYPE => { + base_types.register(TerminalShellAdapter::registered(base_type.as_str())?); + Ok(()) + } RUSTY_CLAWD_BASE_TYPE => { base_types.register(RustyClawdAdapter::registered(base_type.as_str())?); Ok(()) diff --git a/src/identity.rs b/src/identity.rs index 2cdbc3b0d..66bd15081 100644 --- a/src/identity.rs +++ b/src/identity.rs @@ -372,6 +372,7 @@ impl IdentityLoader for BuiltinIdentityLoader { )], vec![ BaseTypeId::new("local-harness"), + BaseTypeId::new("terminal-shell"), BaseTypeId::new("rusty-clawd"), BaseTypeId::new("copilot-sdk"), ], diff --git a/src/lib.rs b/src/lib.rs index b3193a905..61f2d3862 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,6 +15,7 @@ pub mod review; pub mod runtime; mod sanitization; pub mod session; +mod terminal_session; pub use agent_program::{ AgentProgram, AgentProgramContext, AgentProgramMemoryRecord, MeetingFacilitatorProgram, @@ -23,7 +24,7 @@ pub use agent_program::{ pub use base_types::{ BaseTypeCapability, BaseTypeDescriptor, BaseTypeFactory, BaseTypeId, BaseTypeOutcome, BaseTypeSession, BaseTypeSessionRequest, BaseTypeTurnInput, LocalProcessHarnessAdapter, - RustyClawdAdapter, capability_set, + RustyClawdAdapter, TerminalShellAdapter, capability_set, }; pub use bootstrap::{ BootstrapConfig, BootstrapInputs, BootstrapMode, ConfigValue, ConfigValueSource, diff --git a/src/reflection.rs b/src/reflection.rs index bc64cb4be..6ed1c8fff 100644 --- a/src/reflection.rs +++ b/src/reflection.rs @@ -23,6 +23,8 @@ pub struct ReflectionSnapshot { pub agent_program_backend: BackendDescriptor, pub handoff_backend: BackendDescriptor, pub adapter_backend: BackendDescriptor, + pub adapter_capabilities: Vec, + pub adapter_supported_topologies: Vec, pub topology_backend: BackendDescriptor, pub transport_backend: BackendDescriptor, pub supervisor_backend: BackendDescriptor, diff --git a/src/runtime.rs b/src/runtime.rs index d7b729ad7..e1318ef1e 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -940,6 +940,20 @@ impl RuntimeKernel { agent_program_backend: self.ports.agent_program.descriptor(), handoff_backend: self.ports.handoff_store.descriptor(), adapter_backend: self.factory.descriptor().backend.clone(), + adapter_capabilities: self + .factory + .descriptor() + .capabilities + .iter() + .map(ToString::to_string) + .collect(), + adapter_supported_topologies: self + .factory + .descriptor() + .supported_topologies + .iter() + .map(ToString::to_string) + .collect(), topology_backend: self.ports.topology_driver.descriptor(), transport_backend: self.ports.transport.descriptor(), supervisor_backend: self.ports.supervisor.descriptor(), diff --git a/src/terminal_session.rs b/src/terminal_session.rs new file mode 100644 index 000000000..e145a5de2 --- /dev/null +++ b/src/terminal_session.rs @@ -0,0 +1,241 @@ +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use crate::base_types::{ + BaseTypeDescriptor, BaseTypeOutcome, BaseTypeSessionRequest, BaseTypeTurnInput, +}; +use crate::error::{SimardError, SimardResult}; +use crate::sanitization::objective_metadata; + +const DEFAULT_SHELL: &str = "/usr/bin/bash"; +const PTY_LAUNCHER: &str = "script"; + +#[derive(Clone, Debug, Eq, PartialEq)] +struct TerminalTurnSpec { + shell: String, + working_directory: Option, + commands: Vec, +} + +impl TerminalTurnSpec { + fn parse(raw: &str, base_type: &str) -> SimardResult { + let mut shell = None; + let mut working_directory = None; + let mut commands = Vec::new(); + + for line in raw.lines().map(str::trim).filter(|line| !line.is_empty()) { + let Some((label, value)) = line.split_once(':') else { + commands.push(line.to_string()); + continue; + }; + + let label = label.trim().to_ascii_lowercase(); + let value = value.trim(); + if value.is_empty() { + continue; + } + + match label.as_str() { + "shell" => shell = Some(normalize_shell(value, base_type)?), + "working-directory" | "working_directory" | "cwd" => { + working_directory = Some(PathBuf::from(value)) + } + "command" => commands.push(value.to_string()), + _ => commands.push(line.to_string()), + } + } + + if commands.is_empty() { + return Err(SimardError::AdapterInvocationFailed { + base_type: base_type.to_string(), + reason: "terminal-shell requires at least one command line".to_string(), + }); + } + + Ok(Self { + shell: shell.unwrap_or_else(|| DEFAULT_SHELL.to_string()), + working_directory, + commands, + }) + } +} + +pub fn execute_terminal_turn( + descriptor: &BaseTypeDescriptor, + request: &BaseTypeSessionRequest, + input: &BaseTypeTurnInput, +) -> SimardResult { + let spec = TerminalTurnSpec::parse(&input.objective, descriptor.id.as_str())?; + let working_directory = + resolve_working_directory(spec.working_directory.as_deref(), descriptor.id.as_str())?; + let transcript = run_terminal_script(descriptor.id.as_str(), &spec, &working_directory)?; + let transcript_preview = transcript_preview(&transcript); + let objective_summary = objective_metadata(&input.objective); + + Ok(BaseTypeOutcome { + plan: format!( + "Open local PTY shell '{}' in '{}' and run {} terminal command(s) for '{}' on '{}'.", + spec.shell, + working_directory.display(), + spec.commands.len(), + request.mode, + request.topology, + ), + execution_summary: format!( + "Terminal shell session executed {} via selected base type '{}' on implementation '{}' from node '{}' at '{}' with shell '{}' in '{}' across {} terminal command(s).", + objective_summary, + descriptor.id, + descriptor.backend.identity, + request.runtime_node, + request.mailbox_address, + spec.shell, + working_directory.display(), + spec.commands.len(), + ), + evidence: vec![ + format!("selected-base-type={}", descriptor.id), + format!("backend-implementation={}", descriptor.backend.identity), + format!("shell={}", spec.shell), + format!("terminal-working-directory={}", working_directory.display()), + format!("terminal-command-count={}", spec.commands.len()), + format!("terminal-transcript-preview={transcript_preview}"), + format!("runtime-node={}", request.runtime_node), + format!("mailbox-address={}", request.mailbox_address), + ], + }) +} + +fn normalize_shell(value: &str, base_type: &str) -> SimardResult { + let shell = value.trim(); + if shell.is_empty() + || shell.contains('\n') + || shell.contains('\r') + || shell.contains('\'') + || shell.chars().any(char::is_whitespace) + { + return Err(SimardError::AdapterInvocationFailed { + base_type: base_type.to_string(), + reason: + "terminal-shell only accepts a single shell path token without quotes or whitespace" + .to_string(), + }); + } + + Ok(shell.to_string()) +} + +fn resolve_working_directory(path: Option<&Path>, base_type: &str) -> SimardResult { + let cwd = match path { + Some(path) if path.is_absolute() => path.to_path_buf(), + Some(path) => std::env::current_dir() + .map_err(|error| SimardError::AdapterInvocationFailed { + base_type: base_type.to_string(), + reason: format!("failed to resolve current working directory: {error}"), + })? + .join(path), + None => std::env::current_dir().map_err(|error| SimardError::AdapterInvocationFailed { + base_type: base_type.to_string(), + reason: format!("failed to resolve current working directory: {error}"), + })?, + }; + + if !cwd.is_dir() { + return Err(SimardError::AdapterInvocationFailed { + base_type: base_type.to_string(), + reason: format!( + "terminal-shell working directory '{}' does not exist", + cwd.display() + ), + }); + } + + Ok(cwd) +} + +fn run_terminal_script( + base_type: &str, + spec: &TerminalTurnSpec, + working_directory: &Path, +) -> SimardResult { + let launch_command = format!("{} --noprofile --norc -i", spec.shell); + let mut child = Command::new(PTY_LAUNCHER) + .arg("-qefc") + .arg(&launch_command) + .arg("/dev/null") + .current_dir(working_directory) + .env("TERM", "dumb") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|error| SimardError::AdapterInvocationFailed { + base_type: base_type.to_string(), + reason: format!("failed to launch local PTY shell via '{PTY_LAUNCHER}': {error}"), + })?; + + { + let mut stdin = child + .stdin + .take() + .ok_or_else(|| SimardError::AdapterInvocationFailed { + base_type: base_type.to_string(), + reason: "terminal-shell session did not expose stdin".to_string(), + })?; + for command in &spec.commands { + writeln!(stdin, "{command}").map_err(|error| SimardError::AdapterInvocationFailed { + base_type: base_type.to_string(), + reason: format!("failed to write terminal command input: {error}"), + })?; + } + writeln!(stdin, "exit").map_err(|error| SimardError::AdapterInvocationFailed { + base_type: base_type.to_string(), + reason: format!("failed to finalize terminal session input: {error}"), + })?; + } + + let output = + child + .wait_with_output() + .map_err(|error| SimardError::AdapterInvocationFailed { + base_type: base_type.to_string(), + reason: format!("terminal-shell session failed while waiting for output: {error}"), + })?; + + if !output.status.success() { + return Err(SimardError::AdapterInvocationFailed { + base_type: base_type.to_string(), + reason: format!( + "terminal-shell session exited with status {}", + output.status + ), + }); + } + + let mut transcript = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr); + if !stderr.trim().is_empty() { + if !transcript.ends_with('\n') { + transcript.push('\n'); + } + transcript.push_str(&stderr); + } + + Ok(transcript) +} + +fn transcript_preview(transcript: &str) -> String { + let mut normalized = transcript + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect::>() + .join(" | "); + + if normalized.len() > 240 { + normalized.truncate(240); + normalized.push_str("..."); + } + + normalized +} diff --git a/tests/bootstrap.rs b/tests/bootstrap.rs index 7ad879188..50d306f13 100644 --- a/tests/bootstrap.rs +++ b/tests/bootstrap.rs @@ -495,10 +495,18 @@ fn bootstrap_meeting_mode_persists_structured_decision_memory() { #[test] fn bootstrap_assembly_supports_multiple_builtin_manifest_base_types() { - for base_type in ["local-harness", "rusty-clawd", "copilot-sdk"] { + for base_type in [ + "local-harness", + "terminal-shell", + "rusty-clawd", + "copilot-sdk", + ] { let config = BootstrapConfig::resolve(BootstrapInputs { prompt_root: Some(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("prompt_assets")), - objective: Some(format!("exercise base type handling for {base_type}")), + objective: Some(match base_type { + "terminal-shell" => "working-directory: .\ncommand: pwd\ncommand: printf \"terminal-bootstrap-ok\\n\"".to_string(), + _ => format!("exercise base type handling for {base_type}"), + }), identity: Some("simard-engineer".to_string()), base_type: Some(base_type.to_string()), topology: Some("single-process".to_string()), @@ -515,6 +523,7 @@ fn bootstrap_assembly_supports_multiple_builtin_manifest_base_types() { BaseTypeId::new(base_type) ); let expected_backend = match base_type { + "terminal-shell" => "terminal-shell::local-pty", "rusty-clawd" => "rusty-clawd::session-backend", _ => "local-harness", }; @@ -549,6 +558,69 @@ fn bootstrap_assembly_supports_multiple_builtin_manifest_base_types() { } } +#[test] +fn bootstrap_supports_terminal_shell_execution() { + let config = BootstrapConfig::resolve(BootstrapInputs { + prompt_root: Some(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("prompt_assets")), + objective: Some( + "working-directory: .\ncommand: pwd\ncommand: printf \"terminal-foundation-ok\\n\"" + .to_string(), + ), + identity: Some("simard-engineer".to_string()), + base_type: Some("terminal-shell".to_string()), + topology: Some("single-process".to_string()), + state_root: Some(state_root("terminal-shell-execution")), + ..BootstrapInputs::default() + }) + .expect("terminal-shell config should resolve"); + + let execution = + run_local_session(&config).expect("terminal-shell bootstrap run loop should succeed"); + let exported = latest_local_handoff(&config) + .expect("terminal handoff should load") + .expect("terminal handoff should exist"); + + assert_eq!( + execution.snapshot.adapter_backend.identity, + "terminal-shell::local-pty" + ); + assert!( + execution + .snapshot + .adapter_capabilities + .contains(&"terminal-session".to_string()), + "reflection should expose the terminal-session capability" + ); + assert_eq!( + execution.snapshot.adapter_supported_topologies, + vec!["single-process".to_string()] + ); + assert!( + execution + .outcome + .execution_summary + .contains("terminal-shell::local-pty"), + "execution summary should report the terminal-shell backend" + ); + let terminal_evidence = exported + .evidence_records + .iter() + .map(|record| record.detail.as_str()) + .collect::>(); + assert!( + terminal_evidence + .iter() + .any(|detail| detail == &"terminal-command-count=2"), + "terminal evidence should report the interactive command count" + ); + assert!( + terminal_evidence + .iter() + .any(|detail| detail.contains("terminal-foundation-ok")), + "terminal transcript evidence should keep a preview of real terminal output" + ); +} + #[test] fn bootstrap_supports_rusty_clawd_multi_process_execution() { let config = BootstrapConfig::resolve(BootstrapInputs { diff --git a/tests/contracts.rs b/tests/contracts.rs index ecfc207e1..23a462d58 100644 --- a/tests/contracts.rs +++ b/tests/contracts.rs @@ -400,6 +400,20 @@ fn reflection_snapshot_exposes_handoff_backend_descriptor() { ); } +#[test] +fn reflection_snapshot_exposes_adapter_capabilities_and_topologies() { + let reflection_rs = include_str!("../src/reflection.rs"); + + assert!( + reflection_rs.contains("pub adapter_capabilities: Vec"), + "reflection snapshots should expose adapter capabilities for operator-facing introspection" + ); + assert!( + reflection_rs.contains("pub adapter_supported_topologies: Vec"), + "reflection snapshots should expose adapter topology limits for operator-facing introspection" + ); +} + #[test] fn manifest_contract_carries_provenance_and_freshness_directly() { let identity_rs = include_str!("../src/identity.rs"); diff --git a/tests/gadugi/simard-cli-smoke.yaml b/tests/gadugi/simard-cli-smoke.yaml index c7f646bfa..a909db18f 100644 --- a/tests/gadugi/simard-cli-smoke.yaml +++ b/tests/gadugi/simard-cli-smoke.yaml @@ -62,6 +62,13 @@ steps: command: "tests/gadugi/meeting-mode.sh" timeout: 300000 + - name: "Terminal session foundation" + agent: "cli-agent" + action: "execute_command" + params: + command: "tests/gadugi/terminal-session.sh" + timeout: 300000 + - name: "Composite handoff roundtrip" agent: "cli-agent" action: "execute_command" @@ -90,6 +97,7 @@ metadata: - "simard" - "bootstrap" - "meeting" + - "terminal" - "handoff" - "composite" - "benchmark-gym" diff --git a/tests/gadugi/terminal-session.sh b/tests/gadugi/terminal-session.sh new file mode 100755 index 000000000..2ad7d6a23 --- /dev/null +++ b/tests/gadugi/terminal-session.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT" + +OBJECTIVE=$'working-directory: .\ncommand: pwd\ncommand: printf "terminal-foundation-ok\\n"' + +OUTPUT="$( + cargo run --quiet --bin simard_operator_probe -- \ + terminal-run single-process "$OBJECTIVE" +)" + +printf '%s\n' "$OUTPUT" + +printf '%s\n' "$OUTPUT" | grep -F "Probe mode: terminal-run" >/dev/null +printf '%s\n' "$OUTPUT" | grep -F "Identity: simard-engineer" >/dev/null +printf '%s\n' "$OUTPUT" | grep -F "Selected base type: terminal-shell" >/dev/null +printf '%s\n' "$OUTPUT" | grep -F "Topology: single-process" >/dev/null +printf '%s\n' "$OUTPUT" | grep -F "Adapter implementation: terminal-shell::local-pty" >/dev/null +printf '%s\n' "$OUTPUT" | grep -F "Adapter capabilities: prompt-assets, session-lifecycle, memory, evidence, reflection, terminal-session" >/dev/null +printf '%s\n' "$OUTPUT" | grep -F "Session phase: complete" >/dev/null +printf '%s\n' "$OUTPUT" | grep -F "Terminal evidence: terminal-command-count=2" >/dev/null +printf '%s\n' "$OUTPUT" | grep -F "terminal-foundation-ok" >/dev/null