mirror of
https://github.com/openai/codex.git
synced 2026-05-06 04:17:03 +00:00
Compare commits
1 Commits
pr20412
...
bschoepke/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31a9c54dba |
@@ -379,6 +379,9 @@
|
||||
"prevent_idle_sleep": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"ps_repl": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"realtime_conversation": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -502,6 +505,14 @@
|
||||
"plan_mode_reasoning_effort": {
|
||||
"$ref": "#/definitions/ReasoningEffort"
|
||||
},
|
||||
"ps_repl_path": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
}
|
||||
],
|
||||
"description": "Optional absolute path to the PowerShell 7 runtime used by `ps_repl`."
|
||||
},
|
||||
"sandbox_mode": {
|
||||
"$ref": "#/definitions/SandboxMode"
|
||||
},
|
||||
@@ -1750,6 +1761,9 @@
|
||||
"prevent_idle_sleep": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"ps_repl": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"realtime_conversation": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -2088,6 +2102,14 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"ps_repl_path": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
}
|
||||
],
|
||||
"description": "Optional absolute path to the PowerShell 7 runtime used by `ps_repl`."
|
||||
},
|
||||
"review_model": {
|
||||
"description": "Review model override used by the `/review` feature.",
|
||||
"type": "string"
|
||||
|
||||
@@ -88,6 +88,7 @@ pub(crate) async fn apply_role_to_config(
|
||||
cwd: Some(config.cwd.clone()),
|
||||
codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(),
|
||||
main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(),
|
||||
ps_repl_path: config.ps_repl_path.clone(),
|
||||
js_repl_node_path: config.js_repl_node_path.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
|
||||
@@ -277,6 +277,8 @@ use crate::tools::network_approval::NetworkApprovalService;
|
||||
use crate::tools::network_approval::build_blocked_request_observer;
|
||||
use crate::tools::network_approval::build_network_policy_decider;
|
||||
use crate::tools::parallel::ToolCallRuntime;
|
||||
use crate::tools::ps_repl::PsReplHandle;
|
||||
use crate::tools::ps_repl::resolve_compatible_pwsh;
|
||||
use crate::tools::sandboxing::ApprovalStore;
|
||||
use crate::tools::spec::ToolsConfig;
|
||||
use crate::tools::spec::ToolsConfigParams;
|
||||
@@ -378,6 +380,16 @@ impl Codex {
|
||||
config.features.disable(Feature::JsReplToolsOnly);
|
||||
config.startup_warnings.push(message);
|
||||
}
|
||||
if config.features.enabled(Feature::PsRepl)
|
||||
&& let Err(err) = resolve_compatible_pwsh(config.ps_repl_path.as_deref()).await
|
||||
{
|
||||
let message = format!(
|
||||
"Disabled `ps_repl` for this session because the configured PowerShell runtime is unavailable or incompatible. {err}"
|
||||
);
|
||||
warn!("{message}");
|
||||
config.features.disable(Feature::PsRepl);
|
||||
config.startup_warnings.push(message);
|
||||
}
|
||||
|
||||
let allowed_skills_for_implicit_invocation =
|
||||
loaded_skills.allowed_skills_for_implicit_invocation();
|
||||
@@ -608,6 +620,7 @@ pub(crate) struct Session {
|
||||
pub(crate) conversation: Arc<RealtimeConversationManager>,
|
||||
pub(crate) active_turn: Mutex<Option<ActiveTurn>>,
|
||||
pub(crate) services: SessionServices,
|
||||
ps_repl: Arc<PsReplHandle>,
|
||||
js_repl: Arc<JsReplHandle>,
|
||||
next_internal_sub_id: AtomicU64,
|
||||
}
|
||||
@@ -663,6 +676,7 @@ pub(crate) struct TurnContext {
|
||||
pub(crate) codex_linux_sandbox_exe: Option<PathBuf>,
|
||||
pub(crate) tool_call_gate: Arc<ReadinessFlag>,
|
||||
pub(crate) truncation_policy: TruncationPolicy,
|
||||
pub(crate) ps_repl: Arc<PsReplHandle>,
|
||||
pub(crate) js_repl: Arc<JsReplHandle>,
|
||||
pub(crate) dynamic_tools: Vec<DynamicToolSpec>,
|
||||
pub(crate) turn_metadata_state: Arc<TurnMetadataState>,
|
||||
@@ -751,6 +765,7 @@ impl TurnContext {
|
||||
codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.clone(),
|
||||
tool_call_gate: Arc::new(ReadinessFlag::new()),
|
||||
truncation_policy,
|
||||
ps_repl: Arc::clone(&self.ps_repl),
|
||||
js_repl: Arc::clone(&self.js_repl),
|
||||
dynamic_tools: self.dynamic_tools.clone(),
|
||||
turn_metadata_state: self.turn_metadata_state.clone(),
|
||||
@@ -1056,6 +1071,7 @@ impl Session {
|
||||
model_info: ModelInfo,
|
||||
network: Option<NetworkProxy>,
|
||||
sub_id: String,
|
||||
ps_repl: Arc<PsReplHandle>,
|
||||
js_repl: Arc<JsReplHandle>,
|
||||
skills_outcome: Arc<SkillLoadOutcome>,
|
||||
) -> TurnContext {
|
||||
@@ -1125,6 +1141,7 @@ impl Session {
|
||||
codex_linux_sandbox_exe: per_turn_config.codex_linux_sandbox_exe.clone(),
|
||||
tool_call_gate: Arc::new(ReadinessFlag::new()),
|
||||
truncation_policy: model_info.truncation_policy.into(),
|
||||
ps_repl,
|
||||
js_repl,
|
||||
dynamic_tools: session_configuration.dynamic_tools.clone(),
|
||||
turn_metadata_state,
|
||||
@@ -1519,6 +1536,7 @@ impl Session {
|
||||
config.js_repl_node_path.clone(),
|
||||
config.js_repl_node_module_dirs.clone(),
|
||||
));
|
||||
let ps_repl = Arc::new(PsReplHandle::with_pwsh_path(config.ps_repl_path.clone()));
|
||||
|
||||
let sess = Arc::new(Session {
|
||||
conversation_id,
|
||||
@@ -1530,6 +1548,7 @@ impl Session {
|
||||
conversation: Arc::new(RealtimeConversationManager::new()),
|
||||
active_turn: Mutex::new(None),
|
||||
services,
|
||||
ps_repl,
|
||||
js_repl,
|
||||
next_internal_sub_id: AtomicU64::new(0),
|
||||
});
|
||||
@@ -2167,6 +2186,7 @@ impl Session {
|
||||
.as_ref()
|
||||
.map(StartedNetworkProxy::proxy),
|
||||
sub_id,
|
||||
Arc::clone(&self.ps_repl),
|
||||
Arc::clone(&self.js_repl),
|
||||
skills_outcome,
|
||||
);
|
||||
@@ -4695,6 +4715,7 @@ async fn spawn_review_thread(
|
||||
final_output_json_schema: None,
|
||||
codex_linux_sandbox_exe: parent_turn_context.codex_linux_sandbox_exe.clone(),
|
||||
tool_call_gate: Arc::new(ReadinessFlag::new()),
|
||||
ps_repl: Arc::clone(&sess.ps_repl),
|
||||
js_repl: Arc::clone(&sess.js_repl),
|
||||
dynamic_tools: parent_turn_context.dynamic_tools.clone(),
|
||||
truncation_policy: model_info.truncation_policy.into(),
|
||||
@@ -8334,6 +8355,7 @@ mod tests {
|
||||
config.js_repl_node_path.clone(),
|
||||
config.js_repl_node_module_dirs.clone(),
|
||||
));
|
||||
let ps_repl = Arc::new(PsReplHandle::with_pwsh_path(config.ps_repl_path.clone()));
|
||||
|
||||
let skills_outcome = Arc::new(services.skills_manager.skills_for_config(&per_turn_config));
|
||||
let turn_context = Session::make_turn_context(
|
||||
@@ -8345,6 +8367,7 @@ mod tests {
|
||||
model_info,
|
||||
None,
|
||||
"turn_id".to_string(),
|
||||
Arc::clone(&ps_repl),
|
||||
Arc::clone(&js_repl),
|
||||
skills_outcome,
|
||||
);
|
||||
@@ -8359,6 +8382,7 @@ mod tests {
|
||||
conversation: Arc::new(RealtimeConversationManager::new()),
|
||||
active_turn: Mutex::new(None),
|
||||
services,
|
||||
ps_repl,
|
||||
js_repl,
|
||||
next_internal_sub_id: AtomicU64::new(0),
|
||||
};
|
||||
@@ -8502,6 +8526,7 @@ mod tests {
|
||||
config.js_repl_node_path.clone(),
|
||||
config.js_repl_node_module_dirs.clone(),
|
||||
));
|
||||
let ps_repl = Arc::new(PsReplHandle::with_pwsh_path(config.ps_repl_path.clone()));
|
||||
|
||||
let skills_outcome = Arc::new(services.skills_manager.skills_for_config(&per_turn_config));
|
||||
let turn_context = Arc::new(Session::make_turn_context(
|
||||
@@ -8513,6 +8538,7 @@ mod tests {
|
||||
model_info,
|
||||
None,
|
||||
"turn_id".to_string(),
|
||||
Arc::clone(&ps_repl),
|
||||
Arc::clone(&js_repl),
|
||||
skills_outcome,
|
||||
));
|
||||
@@ -8527,6 +8553,7 @@ mod tests {
|
||||
conversation: Arc::new(RealtimeConversationManager::new()),
|
||||
active_turn: Mutex::new(None),
|
||||
services,
|
||||
ps_repl,
|
||||
js_repl,
|
||||
next_internal_sub_id: AtomicU64::new(0),
|
||||
});
|
||||
|
||||
@@ -394,6 +394,9 @@ pub struct Config {
|
||||
/// code via [`ConfigOverrides`].
|
||||
pub main_execve_wrapper_exe: Option<PathBuf>,
|
||||
|
||||
/// Optional absolute path to the PowerShell 7 runtime used by `ps_repl`.
|
||||
pub ps_repl_path: Option<PathBuf>,
|
||||
|
||||
/// Optional absolute path to the Node runtime used by `js_repl`.
|
||||
pub js_repl_node_path: Option<PathBuf>,
|
||||
|
||||
@@ -1121,6 +1124,9 @@ pub struct ConfigToml {
|
||||
/// Default: `300000` (5 minutes).
|
||||
pub background_terminal_max_timeout: Option<u64>,
|
||||
|
||||
/// Optional absolute path to the PowerShell 7 runtime used by `ps_repl`.
|
||||
pub ps_repl_path: Option<AbsolutePathBuf>,
|
||||
|
||||
/// Optional absolute path to the Node runtime used by `js_repl`.
|
||||
pub js_repl_node_path: Option<AbsolutePathBuf>,
|
||||
|
||||
@@ -1553,6 +1559,7 @@ pub struct ConfigOverrides {
|
||||
pub config_profile: Option<String>,
|
||||
pub codex_linux_sandbox_exe: Option<PathBuf>,
|
||||
pub main_execve_wrapper_exe: Option<PathBuf>,
|
||||
pub ps_repl_path: Option<PathBuf>,
|
||||
pub js_repl_node_path: Option<PathBuf>,
|
||||
pub js_repl_node_module_dirs: Option<Vec<PathBuf>>,
|
||||
pub zsh_path: Option<PathBuf>,
|
||||
@@ -1683,6 +1690,7 @@ impl Config {
|
||||
config_profile: config_profile_key,
|
||||
codex_linux_sandbox_exe,
|
||||
main_execve_wrapper_exe,
|
||||
ps_repl_path: ps_repl_path_override,
|
||||
js_repl_node_path: js_repl_node_path_override,
|
||||
js_repl_node_module_dirs: js_repl_node_module_dirs_override,
|
||||
zsh_path: zsh_path_override,
|
||||
@@ -1981,6 +1989,9 @@ impl Config {
|
||||
"experimental compact prompt file",
|
||||
)?;
|
||||
let compact_prompt = compact_prompt.or(file_compact_prompt);
|
||||
let ps_repl_path = ps_repl_path_override
|
||||
.or(config_profile.ps_repl_path.map(Into::into))
|
||||
.or(cfg.ps_repl_path.map(Into::into));
|
||||
let js_repl_node_path = js_repl_node_path_override
|
||||
.or(config_profile.js_repl_node_path.map(Into::into))
|
||||
.or(cfg.js_repl_node_path.map(Into::into));
|
||||
@@ -2150,6 +2161,7 @@ impl Config {
|
||||
file_opener: cfg.file_opener.unwrap_or(UriBasedFileOpener::VsCode),
|
||||
codex_linux_sandbox_exe,
|
||||
main_execve_wrapper_exe,
|
||||
ps_repl_path,
|
||||
js_repl_node_path,
|
||||
js_repl_node_module_dirs,
|
||||
zsh_path,
|
||||
@@ -4909,6 +4921,7 @@ model_verbosity = "high"
|
||||
file_opener: UriBasedFileOpener::VsCode,
|
||||
codex_linux_sandbox_exe: None,
|
||||
main_execve_wrapper_exe: None,
|
||||
ps_repl_path: None,
|
||||
js_repl_node_path: None,
|
||||
js_repl_node_module_dirs: Vec::new(),
|
||||
zsh_path: None,
|
||||
@@ -5037,6 +5050,7 @@ model_verbosity = "high"
|
||||
file_opener: UriBasedFileOpener::VsCode,
|
||||
codex_linux_sandbox_exe: None,
|
||||
main_execve_wrapper_exe: None,
|
||||
ps_repl_path: None,
|
||||
js_repl_node_path: None,
|
||||
js_repl_node_module_dirs: Vec::new(),
|
||||
zsh_path: None,
|
||||
@@ -5163,6 +5177,7 @@ model_verbosity = "high"
|
||||
file_opener: UriBasedFileOpener::VsCode,
|
||||
codex_linux_sandbox_exe: None,
|
||||
main_execve_wrapper_exe: None,
|
||||
ps_repl_path: None,
|
||||
js_repl_node_path: None,
|
||||
js_repl_node_module_dirs: Vec::new(),
|
||||
zsh_path: None,
|
||||
@@ -5275,6 +5290,7 @@ model_verbosity = "high"
|
||||
file_opener: UriBasedFileOpener::VsCode,
|
||||
codex_linux_sandbox_exe: None,
|
||||
main_execve_wrapper_exe: None,
|
||||
ps_repl_path: None,
|
||||
js_repl_node_path: None,
|
||||
js_repl_node_module_dirs: Vec::new(),
|
||||
zsh_path: None,
|
||||
|
||||
@@ -33,6 +33,8 @@ pub struct ConfigProfile {
|
||||
pub chatgpt_base_url: Option<String>,
|
||||
/// Optional path to a file containing model instructions.
|
||||
pub model_instructions_file: Option<AbsolutePathBuf>,
|
||||
/// Optional absolute path to the PowerShell 7 runtime used by `ps_repl`.
|
||||
pub ps_repl_path: Option<AbsolutePathBuf>,
|
||||
pub js_repl_node_path: Option<AbsolutePathBuf>,
|
||||
/// Ordered list of directories to search for Node modules in `js_repl`.
|
||||
pub js_repl_node_module_dirs: Option<Vec<AbsolutePathBuf>>,
|
||||
|
||||
@@ -78,6 +78,8 @@ pub enum Feature {
|
||||
ShellTool,
|
||||
|
||||
// Experimental
|
||||
/// Enable PowerShell REPL tools backed by a persistent pwsh kernel.
|
||||
PsRepl,
|
||||
/// Enable JavaScript REPL tools backed by a persistent Node kernel.
|
||||
JsRepl,
|
||||
/// Only expose js_repl tools directly to the model.
|
||||
@@ -460,6 +462,12 @@ pub const FEATURES: &[FeatureSpec] = &[
|
||||
stage: Stage::Stable,
|
||||
default_enabled: true,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::PsRepl,
|
||||
key: "ps_repl",
|
||||
stage: Stage::UnderDevelopment,
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::JsRepl,
|
||||
key: "js_repl",
|
||||
@@ -825,6 +833,12 @@ mod tests {
|
||||
assert_eq!(Feature::JsRepl.default_enabled(), false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ps_repl_is_under_development_and_disabled_by_default() {
|
||||
assert_eq!(Feature::PsRepl.stage(), Stage::UnderDevelopment);
|
||||
assert_eq!(Feature::PsRepl.default_enabled(), false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collab_is_legacy_alias_for_multi_agent() {
|
||||
assert_eq!(feature_for_key("multi_agent"), Some(Feature::Collab));
|
||||
|
||||
@@ -69,6 +69,25 @@ fn render_js_repl_instructions(config: &Config) -> Option<String> {
|
||||
Some(section)
|
||||
}
|
||||
|
||||
fn render_ps_repl_instructions(config: &Config) -> Option<String> {
|
||||
if !config.features.enabled(Feature::PsRepl) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut section = String::from("## PowerShell REPL (pwsh)\n");
|
||||
section.push_str(
|
||||
"- Use `ps_repl` for PowerShell-backed automation in a persistent `pwsh` kernel.\n",
|
||||
);
|
||||
section.push_str("- `ps_repl` is a freeform/custom tool. Direct `ps_repl` calls must send raw PowerShell input (optionally with first-line `# codex-ps-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n");
|
||||
section.push_str("- Helpers: `$CodexTmpDir`, `Invoke-CodexTool -Name <string> -Arguments <object|string>`, `$Codex.TmpDir`, and `$Codex.Tool(<name>, <args>)`.\n");
|
||||
section.push_str("- `Invoke-CodexTool` returns the raw tool output object. Use it for built-in tools, dynamic tools, and MCP tools.\n");
|
||||
section.push_str("- PowerShell session state persists across calls, including variables, functions, aliases, imported modules, environment changes, and `$LASTEXITCODE`. Reset the kernel with `ps_repl_reset` when needed.\n");
|
||||
section.push_str("- To share generated images with the model, write a file under `$CodexTmpDir`, call `Invoke-CodexTool -Name view_image -Arguments @{ path = \"/absolute/path\" }`, then delete the file.\n");
|
||||
section.push_str("- Avoid direct `[Console]::Write*`, raw StdOut/StdErr writes, or other host-level output that bypasses PowerShell streams; they can corrupt the JSON line protocol. Use pipeline output, `Write-Output`, `Write-Host`, `Write-Verbose`, or `Write-Warning` instead.");
|
||||
|
||||
Some(section)
|
||||
}
|
||||
|
||||
/// Combines `Config::instructions` and `AGENTS.md` (if present) into a single
|
||||
/// string of instructions.
|
||||
pub(crate) async fn get_user_instructions(
|
||||
@@ -103,6 +122,13 @@ pub(crate) async fn get_user_instructions(
|
||||
output.push_str(&js_repl_section);
|
||||
}
|
||||
|
||||
if let Some(ps_repl_section) = render_ps_repl_instructions(config) {
|
||||
if !output.is_empty() {
|
||||
output.push_str("\n\n");
|
||||
}
|
||||
output.push_str(&ps_repl_section);
|
||||
}
|
||||
|
||||
let skills_section = skills.and_then(render_skills_section);
|
||||
if let Some(skills_section) = skills_section {
|
||||
if !output.is_empty() {
|
||||
@@ -492,6 +518,19 @@ mod tests {
|
||||
assert_eq!(res, expected);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ps_repl_instructions_are_appended_when_enabled() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let mut cfg = make_config(&tmp, 4096, None).await;
|
||||
cfg.features.enable(Feature::PsRepl);
|
||||
|
||||
let res = get_user_instructions(&cfg, None)
|
||||
.await
|
||||
.expect("ps_repl instructions expected");
|
||||
let expected = "## PowerShell REPL (pwsh)\n- Use `ps_repl` for PowerShell-backed automation in a persistent `pwsh` kernel.\n- `ps_repl` is a freeform/custom tool. Direct `ps_repl` calls must send raw PowerShell input (optionally with first-line `# codex-ps-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n- Helpers: `$CodexTmpDir`, `Invoke-CodexTool -Name <string> -Arguments <object|string>`, `$Codex.TmpDir`, and `$Codex.Tool(<name>, <args>)`.\n- `Invoke-CodexTool` returns the raw tool output object. Use it for built-in tools, dynamic tools, and MCP tools.\n- PowerShell session state persists across calls, including variables, functions, aliases, imported modules, environment changes, and `$LASTEXITCODE`. Reset the kernel with `ps_repl_reset` when needed.\n- To share generated images with the model, write a file under `$CodexTmpDir`, call `Invoke-CodexTool -Name view_image -Arguments @{ path = \"/absolute/path\" }`, then delete the file.\n- Avoid direct `[Console]::Write*`, raw StdOut/StdErr writes, or other host-level output that bypasses PowerShell streams; they can corrupt the JSON line protocol. Use pipeline output, `Write-Output`, `Write-Host`, `Write-Verbose`, or `Write-Warning` instead.";
|
||||
assert_eq!(res, expected);
|
||||
}
|
||||
|
||||
/// When both system instructions *and* a project doc are present the two
|
||||
/// should be concatenated with the separator.
|
||||
#[tokio::test]
|
||||
|
||||
@@ -20,6 +20,7 @@ pub type SharedTurnDiffTracker = Arc<Mutex<TurnDiffTracker>>;
|
||||
pub enum ToolCallSource {
|
||||
Direct,
|
||||
JsRepl,
|
||||
PsRepl,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
|
||||
@@ -8,6 +8,7 @@ mod mcp;
|
||||
mod mcp_resource;
|
||||
pub(crate) mod multi_agents;
|
||||
mod plan;
|
||||
mod ps_repl;
|
||||
mod read_file;
|
||||
mod request_user_input;
|
||||
mod search_tool_bm25;
|
||||
@@ -38,6 +39,8 @@ pub use mcp::McpHandler;
|
||||
pub use mcp_resource::McpResourceHandler;
|
||||
pub use multi_agents::MultiAgentHandler;
|
||||
pub use plan::PlanHandler;
|
||||
pub use ps_repl::PsReplHandler;
|
||||
pub use ps_repl::PsReplResetHandler;
|
||||
pub use read_file::ReadFileHandler;
|
||||
pub use request_user_input::RequestUserInputHandler;
|
||||
pub(crate) use request_user_input::request_user_input_tool_description;
|
||||
|
||||
363
codex-rs/core/src/tools/handlers/ps_repl.rs
Normal file
363
codex-rs/core/src/tools/handlers/ps_repl.rs
Normal file
@@ -0,0 +1,363 @@
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::exec::ExecToolCallOutput;
|
||||
use crate::exec::StreamOutput;
|
||||
use crate::features::Feature;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::protocol::ExecCommandSource;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolOutput;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use crate::tools::events::ToolEmitter;
|
||||
use crate::tools::events::ToolEventCtx;
|
||||
use crate::tools::events::ToolEventFailure;
|
||||
use crate::tools::events::ToolEventStage;
|
||||
use crate::tools::handlers::parse_arguments;
|
||||
use crate::tools::ps_repl::PS_REPL_PRAGMA_PREFIX;
|
||||
use crate::tools::ps_repl::PsExecResult;
|
||||
use crate::tools::ps_repl::PsReplArgs;
|
||||
use crate::tools::registry::ToolHandler;
|
||||
use crate::tools::registry::ToolKind;
|
||||
use codex_protocol::models::FunctionCallOutputBody;
|
||||
use codex_protocol::models::FunctionCallOutputContentItem;
|
||||
|
||||
pub struct PsReplHandler;
|
||||
pub struct PsReplResetHandler;
|
||||
|
||||
fn join_outputs(stdout: &str, stderr: &str) -> String {
|
||||
if stdout.is_empty() {
|
||||
stderr.to_string()
|
||||
} else if stderr.is_empty() {
|
||||
stdout.to_string()
|
||||
} else {
|
||||
format!("{stdout}\n{stderr}")
|
||||
}
|
||||
}
|
||||
|
||||
fn build_ps_repl_exec_output(
|
||||
output: &str,
|
||||
error: Option<&str>,
|
||||
duration: Duration,
|
||||
) -> ExecToolCallOutput {
|
||||
let stdout = output.to_string();
|
||||
let stderr = error.unwrap_or("").to_string();
|
||||
let aggregated_output = join_outputs(&stdout, &stderr);
|
||||
ExecToolCallOutput {
|
||||
exit_code: if error.is_some() { 1 } else { 0 },
|
||||
stdout: StreamOutput::new(stdout),
|
||||
stderr: StreamOutput::new(stderr),
|
||||
aggregated_output: StreamOutput::new(aggregated_output),
|
||||
duration,
|
||||
timed_out: false,
|
||||
}
|
||||
}
|
||||
|
||||
async fn emit_ps_repl_exec_begin(
|
||||
session: &crate::codex::Session,
|
||||
turn: &crate::codex::TurnContext,
|
||||
call_id: &str,
|
||||
) {
|
||||
let emitter = ToolEmitter::shell(
|
||||
vec!["ps_repl".to_string()],
|
||||
turn.cwd.clone(),
|
||||
ExecCommandSource::Agent,
|
||||
false,
|
||||
);
|
||||
let ctx = ToolEventCtx::new(session, turn, call_id, None);
|
||||
emitter.emit(ctx, ToolEventStage::Begin).await;
|
||||
}
|
||||
|
||||
async fn emit_ps_repl_exec_end(
|
||||
session: &crate::codex::Session,
|
||||
turn: &crate::codex::TurnContext,
|
||||
call_id: &str,
|
||||
output: &str,
|
||||
error: Option<&str>,
|
||||
duration: Duration,
|
||||
) {
|
||||
let exec_output = build_ps_repl_exec_output(output, error, duration);
|
||||
let emitter = ToolEmitter::shell(
|
||||
vec!["ps_repl".to_string()],
|
||||
turn.cwd.clone(),
|
||||
ExecCommandSource::Agent,
|
||||
false,
|
||||
);
|
||||
let ctx = ToolEventCtx::new(session, turn, call_id, None);
|
||||
let stage = if error.is_some() {
|
||||
ToolEventStage::Failure(ToolEventFailure::Output(exec_output))
|
||||
} else {
|
||||
ToolEventStage::Success(exec_output)
|
||||
};
|
||||
emitter.emit(ctx, stage).await;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ToolHandler for PsReplHandler {
|
||||
fn kind(&self) -> ToolKind {
|
||||
ToolKind::Function
|
||||
}
|
||||
|
||||
fn matches_kind(&self, payload: &ToolPayload) -> bool {
|
||||
matches!(
|
||||
payload,
|
||||
ToolPayload::Function { .. } | ToolPayload::Custom { .. }
|
||||
)
|
||||
}
|
||||
|
||||
async fn handle(&self, invocation: ToolInvocation) -> Result<ToolOutput, FunctionCallError> {
|
||||
let ToolInvocation {
|
||||
session,
|
||||
turn,
|
||||
tracker,
|
||||
payload,
|
||||
call_id,
|
||||
..
|
||||
} = invocation;
|
||||
|
||||
if !session.features().enabled(Feature::PsRepl) {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"ps_repl is disabled by feature flag".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let args = match payload {
|
||||
ToolPayload::Function { arguments } => parse_arguments(&arguments)?,
|
||||
ToolPayload::Custom { input } => parse_freeform_args(&input)?,
|
||||
_ => {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"ps_repl expects custom or function payload".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let manager = turn.ps_repl.manager().await?;
|
||||
let started_at = Instant::now();
|
||||
emit_ps_repl_exec_begin(session.as_ref(), turn.as_ref(), &call_id).await;
|
||||
let result = manager
|
||||
.execute(Arc::clone(&session), Arc::clone(&turn), tracker, args)
|
||||
.await;
|
||||
let result = match result {
|
||||
Ok(result) => result,
|
||||
Err(err) => {
|
||||
let message = err.to_string();
|
||||
emit_ps_repl_exec_end(
|
||||
session.as_ref(),
|
||||
turn.as_ref(),
|
||||
&call_id,
|
||||
"",
|
||||
Some(&message),
|
||||
started_at.elapsed(),
|
||||
)
|
||||
.await;
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
|
||||
emit_ps_repl_exec_end(
|
||||
session.as_ref(),
|
||||
turn.as_ref(),
|
||||
&call_id,
|
||||
&result.output,
|
||||
None,
|
||||
started_at.elapsed(),
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(build_tool_output(result))
|
||||
}
|
||||
}
|
||||
|
||||
fn build_tool_output(result: PsExecResult) -> ToolOutput {
|
||||
let PsExecResult {
|
||||
output,
|
||||
content_items,
|
||||
} = result;
|
||||
let mut items = Vec::with_capacity(content_items.len() + 1);
|
||||
if !output.is_empty() {
|
||||
items.push(FunctionCallOutputContentItem::InputText {
|
||||
text: output.clone(),
|
||||
});
|
||||
}
|
||||
items.extend(content_items);
|
||||
|
||||
ToolOutput::Function {
|
||||
body: if items.is_empty() {
|
||||
FunctionCallOutputBody::Text(output)
|
||||
} else {
|
||||
FunctionCallOutputBody::ContentItems(items)
|
||||
},
|
||||
success: Some(true),
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ToolHandler for PsReplResetHandler {
|
||||
fn kind(&self) -> ToolKind {
|
||||
ToolKind::Function
|
||||
}
|
||||
|
||||
async fn handle(&self, invocation: ToolInvocation) -> Result<ToolOutput, FunctionCallError> {
|
||||
if !invocation.session.features().enabled(Feature::PsRepl) {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"ps_repl is disabled by feature flag".to_string(),
|
||||
));
|
||||
}
|
||||
let manager = invocation.turn.ps_repl.manager().await?;
|
||||
manager.reset().await?;
|
||||
Ok(ToolOutput::Function {
|
||||
body: FunctionCallOutputBody::Text("ps_repl kernel reset".to_string()),
|
||||
success: Some(true),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_freeform_args(input: &str) -> Result<PsReplArgs, FunctionCallError> {
|
||||
if input.trim().is_empty() {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"ps_repl expects raw PowerShell tool input (non-empty). Provide PowerShell source text, optionally with first-line `# codex-ps-repl: ...`."
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut args = PsReplArgs {
|
||||
code: input.to_string(),
|
||||
timeout_ms: None,
|
||||
};
|
||||
|
||||
let mut lines = input.splitn(2, '\n');
|
||||
let first_line = lines.next().unwrap_or_default();
|
||||
let rest = lines.next().unwrap_or_default();
|
||||
let trimmed = first_line.trim_start();
|
||||
let Some(pragma) = trimmed.strip_prefix(PS_REPL_PRAGMA_PREFIX) else {
|
||||
reject_json_or_quoted_source(&args.code)?;
|
||||
return Ok(args);
|
||||
};
|
||||
|
||||
let mut timeout_ms: Option<u64> = None;
|
||||
let directive = pragma.trim();
|
||||
if !directive.is_empty() {
|
||||
for token in directive.split_whitespace() {
|
||||
let (key, value) = token.split_once('=').ok_or_else(|| {
|
||||
FunctionCallError::RespondToModel(format!(
|
||||
"ps_repl pragma expects space-separated key=value pairs (supported keys: timeout_ms); got `{token}`"
|
||||
))
|
||||
})?;
|
||||
match key {
|
||||
"timeout_ms" => {
|
||||
if timeout_ms.is_some() {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"ps_repl pragma specifies timeout_ms more than once".to_string(),
|
||||
));
|
||||
}
|
||||
let parsed = value.parse::<u64>().map_err(|_| {
|
||||
FunctionCallError::RespondToModel(format!(
|
||||
"ps_repl pragma timeout_ms must be an integer; got `{value}`"
|
||||
))
|
||||
})?;
|
||||
timeout_ms = Some(parsed);
|
||||
}
|
||||
_ => {
|
||||
return Err(FunctionCallError::RespondToModel(format!(
|
||||
"ps_repl pragma only supports timeout_ms; got `{key}`"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if rest.trim().is_empty() {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"ps_repl pragma must be followed by PowerShell source on subsequent lines".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
reject_json_or_quoted_source(rest)?;
|
||||
args.code = rest.to_string();
|
||||
args.timeout_ms = timeout_ms;
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
fn reject_json_or_quoted_source(code: &str) -> Result<(), FunctionCallError> {
|
||||
let trimmed = code.trim();
|
||||
if trimmed.starts_with("```") {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"ps_repl expects raw PowerShell source, not markdown code fences. Resend plain PowerShell only (optional first line `# codex-ps-repl: ...`)."
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
if is_quoted_source(trimmed) {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"ps_repl is a freeform tool and expects raw PowerShell source. Resend plain PowerShell only (optional first line `# codex-ps-repl: ...`); do not send quoted code or markdown fences."
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
let Ok(value) = serde_json::from_str::<JsonValue>(trimmed) else {
|
||||
return Ok(());
|
||||
};
|
||||
match value {
|
||||
JsonValue::Object(_) | JsonValue::String(_) => Err(FunctionCallError::RespondToModel(
|
||||
"ps_repl is a freeform tool and expects raw PowerShell source. Resend plain PowerShell only (optional first line `# codex-ps-repl: ...`); do not send JSON (`{\"code\":...}`), quoted code, or markdown fences."
|
||||
.to_string(),
|
||||
)),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_quoted_source(input: &str) -> bool {
|
||||
input.len() >= 2
|
||||
&& ((input.starts_with('"') && input.ends_with('"'))
|
||||
|| (input.starts_with('\'') && input.ends_with('\'')))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::parse_freeform_args;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn parse_freeform_args_without_pragma() {
|
||||
let args = parse_freeform_args("Write-Output 'ok'").expect("parse args");
|
||||
assert_eq!(args.code, "Write-Output 'ok'");
|
||||
assert_eq!(args.timeout_ms, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_freeform_args_with_pragma() {
|
||||
let input = "# codex-ps-repl: timeout_ms=15000\nWrite-Output 'ok'";
|
||||
let args = parse_freeform_args(input).expect("parse args");
|
||||
assert_eq!(args.code, "Write-Output 'ok'");
|
||||
assert_eq!(args.timeout_ms, Some(15_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_freeform_args_rejects_unknown_key() {
|
||||
let err = parse_freeform_args("# codex-ps-repl: nope=1\nWrite-Output 'ok'")
|
||||
.expect_err("expected error");
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"ps_repl pragma only supports timeout_ms; got `nope`"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_freeform_args_rejects_json_wrapped_code() {
|
||||
let err =
|
||||
parse_freeform_args(r#"{"code":"Write-Output 'ok'"}"#).expect_err("expected error");
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"ps_repl is a freeform tool and expects raw PowerShell source. Resend plain PowerShell only (optional first line `# codex-ps-repl: ...`); do not send JSON (`{\"code\":...}`), quoted code, or markdown fences."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_freeform_args_rejects_quoted_source() {
|
||||
let err = parse_freeform_args("'Write-Output ok'").expect_err("expected error");
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"ps_repl is a freeform tool and expects raw PowerShell source. Resend plain PowerShell only (optional first line `# codex-ps-repl: ...`); do not send quoted code or markdown fences."
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ pub mod js_repl;
|
||||
pub(crate) mod network_approval;
|
||||
pub mod orchestrator;
|
||||
pub mod parallel;
|
||||
pub mod ps_repl;
|
||||
pub mod registry;
|
||||
pub mod router;
|
||||
pub mod runtimes;
|
||||
|
||||
187
codex-rs/core/src/tools/ps_repl/kernel.ps1
Normal file
187
codex-rs/core/src/tools/ps_repl/kernel.ps1
Normal file
@@ -0,0 +1,187 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
[Console]::InputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
$script:CodexTmpDir = if ($env:CODEX_PS_TMP_DIR) {
|
||||
$env:CODEX_PS_TMP_DIR
|
||||
} else {
|
||||
(Get-Location).Path
|
||||
}
|
||||
$script:ToolCounter = 0
|
||||
$script:ActiveExecId = $null
|
||||
|
||||
function Send-KernelMessage {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[hashtable]$Message
|
||||
)
|
||||
|
||||
$json = $Message | ConvertTo-Json -Compress -Depth 100
|
||||
[Console]::Out.WriteLine($json)
|
||||
[Console]::Out.Flush()
|
||||
}
|
||||
|
||||
function Read-KernelMessage {
|
||||
while ($true) {
|
||||
$line = [Console]::In.ReadLine()
|
||||
if ($null -eq $line) {
|
||||
return $null
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($line)) {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
return $line | ConvertFrom-Json -AsHashtable -Depth 100
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Format-StreamItem {
|
||||
param($Item)
|
||||
|
||||
if ($null -eq $Item) {
|
||||
return $null
|
||||
}
|
||||
if ($Item -is [string]) {
|
||||
return $Item.TrimEnd("`r", "`n")
|
||||
}
|
||||
|
||||
$text = $Item | Out-String -Width 4096
|
||||
$trimmed = $text.TrimEnd("`r", "`n")
|
||||
if ([string]::IsNullOrEmpty($trimmed)) {
|
||||
return $null
|
||||
}
|
||||
$trimmed
|
||||
}
|
||||
|
||||
function Wait-ToolResult {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Id
|
||||
)
|
||||
|
||||
while ($true) {
|
||||
$message = Read-KernelMessage
|
||||
if ($null -eq $message) {
|
||||
throw "ps_repl kernel closed while waiting for tool result"
|
||||
}
|
||||
if ($message.type -ne 'run_tool_result') {
|
||||
throw "ps_repl kernel received unexpected message while waiting for tool result: $($message.type)"
|
||||
}
|
||||
if ($message.id -ne $Id) {
|
||||
throw "ps_repl kernel received mismatched tool result: expected $Id, got $($message.id)"
|
||||
}
|
||||
return $message
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-CodexTool {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Name,
|
||||
[object]$Arguments
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Name)) {
|
||||
throw "Invoke-CodexTool expects a non-empty tool name"
|
||||
}
|
||||
if ($null -eq $script:ActiveExecId) {
|
||||
throw "Invoke-CodexTool can only be used while a ps_repl exec is running"
|
||||
}
|
||||
|
||||
$toolId = "{0}-tool-{1}" -f $script:ActiveExecId, $script:ToolCounter
|
||||
$script:ToolCounter += 1
|
||||
|
||||
$argumentsJson = '{}'
|
||||
if ($PSBoundParameters.ContainsKey('Arguments')) {
|
||||
if ($Arguments -is [string]) {
|
||||
$argumentsJson = $Arguments
|
||||
} else {
|
||||
$argumentsJson = $Arguments | ConvertTo-Json -Compress -Depth 100
|
||||
}
|
||||
}
|
||||
|
||||
Send-KernelMessage @{
|
||||
type = 'run_tool'
|
||||
id = $toolId
|
||||
exec_id = $script:ActiveExecId
|
||||
tool_name = $Name
|
||||
arguments = $argumentsJson
|
||||
}
|
||||
|
||||
$result = Wait-ToolResult -Id $toolId
|
||||
if (-not $result.ok) {
|
||||
if ($null -ne $result.error -and -not [string]::IsNullOrWhiteSpace([string]$result.error)) {
|
||||
throw [System.Exception]::new([string]$result.error)
|
||||
}
|
||||
throw [System.Exception]::new('tool failed')
|
||||
}
|
||||
|
||||
$result.response
|
||||
}
|
||||
|
||||
$script:Codex = [pscustomobject]@{
|
||||
TmpDir = $script:CodexTmpDir
|
||||
}
|
||||
$null = $script:Codex | Add-Member -MemberType ScriptMethod -Name Tool -Value {
|
||||
param($Name, $Arguments)
|
||||
|
||||
if ($PSBoundParameters.ContainsKey('Arguments')) {
|
||||
Invoke-CodexTool -Name $Name -Arguments $Arguments
|
||||
} else {
|
||||
Invoke-CodexTool -Name $Name
|
||||
}
|
||||
}
|
||||
Set-Variable -Name Codex -Scope Script -Value $script:Codex
|
||||
Set-Variable -Name CodexTmpDir -Scope Script -Value $script:CodexTmpDir
|
||||
|
||||
while ($true) {
|
||||
$message = Read-KernelMessage
|
||||
if ($null -eq $message) {
|
||||
break
|
||||
}
|
||||
if ($message.type -ne 'exec') {
|
||||
continue
|
||||
}
|
||||
|
||||
$script:ActiveExecId = [string]$message.id
|
||||
|
||||
try {
|
||||
$scriptBlock = [scriptblock]::Create([string]$message.code)
|
||||
$items = @(. $scriptBlock *>&1)
|
||||
$outputLines = foreach ($item in $items) {
|
||||
$formatted = Format-StreamItem -Item $item
|
||||
if ($null -ne $formatted -and $formatted -ne '') {
|
||||
$formatted
|
||||
}
|
||||
}
|
||||
$output = [string]::Join("`n", @($outputLines))
|
||||
Send-KernelMessage @{
|
||||
type = 'exec_result'
|
||||
id = [string]$message.id
|
||||
ok = $true
|
||||
output = $output
|
||||
error = $null
|
||||
}
|
||||
} catch {
|
||||
$errorMessage = if ($_.Exception -and $_.Exception.Message) {
|
||||
[string]$_.Exception.Message
|
||||
} else {
|
||||
[string]$_
|
||||
}
|
||||
Send-KernelMessage @{
|
||||
type = 'exec_result'
|
||||
id = [string]$message.id
|
||||
ok = $false
|
||||
output = ''
|
||||
error = $errorMessage
|
||||
}
|
||||
} finally {
|
||||
$script:ActiveExecId = $null
|
||||
}
|
||||
}
|
||||
1686
codex-rs/core/src/tools/ps_repl/mod.rs
Normal file
1686
codex-rs/core/src/tools/ps_repl/mod.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -52,6 +52,7 @@ pub(crate) struct ToolsConfig {
|
||||
pub agent_roles: BTreeMap<String, AgentRoleConfig>,
|
||||
pub search_tool: bool,
|
||||
pub request_permission_enabled: bool,
|
||||
pub ps_repl_enabled: bool,
|
||||
pub js_repl_enabled: bool,
|
||||
pub js_repl_tools_only: bool,
|
||||
pub collab_tools: bool,
|
||||
@@ -77,6 +78,7 @@ impl ToolsConfig {
|
||||
session_source,
|
||||
} = params;
|
||||
let include_apply_patch_tool = features.enabled(Feature::ApplyPatchFreeform);
|
||||
let include_ps_repl = features.enabled(Feature::PsRepl);
|
||||
let include_js_repl = features.enabled(Feature::JsRepl);
|
||||
let include_js_repl_tools_only =
|
||||
include_js_repl && features.enabled(Feature::JsReplToolsOnly);
|
||||
@@ -136,6 +138,7 @@ impl ToolsConfig {
|
||||
agent_roles: BTreeMap::new(),
|
||||
search_tool: include_search_tool,
|
||||
request_permission_enabled,
|
||||
ps_repl_enabled: include_ps_repl,
|
||||
js_repl_enabled: include_js_repl,
|
||||
js_repl_tools_only: include_js_repl_tools_only,
|
||||
collab_tools: include_collab_tools,
|
||||
@@ -1329,6 +1332,47 @@ JS_SOURCE: /(?:\s*)(?:[^\s{\"`]|`[^`]|``[^`])[\s\S]*/
|
||||
})
|
||||
}
|
||||
|
||||
fn create_ps_repl_tool() -> ToolSpec {
|
||||
const PS_REPL_FREEFORM_GRAMMAR: &str = r#"
|
||||
start: pragma_source | plain_source
|
||||
|
||||
pragma_source: PRAGMA_LINE NEWLINE ps_source
|
||||
plain_source: PS_SOURCE
|
||||
|
||||
ps_source: PS_SOURCE
|
||||
|
||||
PRAGMA_LINE: /[ \t]*# codex-ps-repl:[^\r\n]*/
|
||||
NEWLINE: /\r?\n/
|
||||
PS_SOURCE: /(?:\s*)(?:[^\s{\"'`]|#[^\r\n]|`[^`])[\s\S]*/
|
||||
"#;
|
||||
|
||||
ToolSpec::Freeform(FreeformTool {
|
||||
name: "ps_repl".to_string(),
|
||||
description: "Runs PowerShell in a persistent pwsh kernel. This is a freeform tool: send raw PowerShell source text, optionally with a first-line pragma like `# codex-ps-repl: timeout_ms=15000`; do not send JSON/quotes/markdown fences."
|
||||
.to_string(),
|
||||
format: FreeformToolFormat {
|
||||
r#type: "grammar".to_string(),
|
||||
syntax: "lark".to_string(),
|
||||
definition: PS_REPL_FREEFORM_GRAMMAR.to_string(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn create_ps_repl_reset_tool() -> ToolSpec {
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "ps_repl_reset".to_string(),
|
||||
description:
|
||||
"Restarts the ps_repl kernel for this run and clears persisted PowerShell session state."
|
||||
.to_string(),
|
||||
strict: false,
|
||||
parameters: JsonSchema::Object {
|
||||
properties: BTreeMap::new(),
|
||||
required: None,
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn create_js_repl_reset_tool() -> ToolSpec {
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "js_repl_reset".to_string(),
|
||||
@@ -1658,6 +1702,8 @@ pub(crate) fn build_specs(
|
||||
use crate::tools::handlers::McpResourceHandler;
|
||||
use crate::tools::handlers::MultiAgentHandler;
|
||||
use crate::tools::handlers::PlanHandler;
|
||||
use crate::tools::handlers::PsReplHandler;
|
||||
use crate::tools::handlers::PsReplResetHandler;
|
||||
use crate::tools::handlers::ReadFileHandler;
|
||||
use crate::tools::handlers::RequestUserInputHandler;
|
||||
use crate::tools::handlers::SearchToolBm25Handler;
|
||||
@@ -1683,6 +1729,8 @@ pub(crate) fn build_specs(
|
||||
default_mode_request_user_input: config.default_mode_request_user_input,
|
||||
});
|
||||
let search_tool_handler = Arc::new(SearchToolBm25Handler);
|
||||
let ps_repl_handler = Arc::new(PsReplHandler);
|
||||
let ps_repl_reset_handler = Arc::new(PsReplResetHandler);
|
||||
let js_repl_handler = Arc::new(JsReplHandler);
|
||||
let js_repl_reset_handler = Arc::new(JsReplResetHandler);
|
||||
let request_permission_enabled = config.request_permission_enabled;
|
||||
@@ -1737,6 +1785,13 @@ pub(crate) fn build_specs(
|
||||
builder.push_spec(PLAN_TOOL.clone());
|
||||
builder.register_handler("update_plan", plan_handler);
|
||||
|
||||
if config.ps_repl_enabled {
|
||||
builder.push_spec(create_ps_repl_tool());
|
||||
builder.push_spec(create_ps_repl_reset_tool());
|
||||
builder.register_handler("ps_repl", ps_repl_handler);
|
||||
builder.register_handler("ps_repl_reset", ps_repl_reset_handler);
|
||||
}
|
||||
|
||||
if config.js_repl_enabled {
|
||||
builder.push_spec(create_js_repl_tool());
|
||||
builder.push_spec(create_js_repl_reset_tool());
|
||||
@@ -2234,6 +2289,62 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ps_repl_requires_feature_flag() {
|
||||
let config = test_config();
|
||||
let model_info =
|
||||
ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
|
||||
let features = Features::with_defaults();
|
||||
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
features: &features,
|
||||
web_search_mode: Some(WebSearchMode::Cached),
|
||||
session_source: SessionSource::Cli,
|
||||
});
|
||||
let (tools, _) = build_specs(&tools_config, None, None, &[]).build();
|
||||
|
||||
assert!(
|
||||
!tools.iter().any(|tool| tool.spec.name() == "ps_repl"),
|
||||
"ps_repl should be disabled when the feature is off"
|
||||
);
|
||||
assert!(
|
||||
!tools.iter().any(|tool| tool.spec.name() == "ps_repl_reset"),
|
||||
"ps_repl_reset should be disabled when the feature is off"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ps_repl_enabled_adds_tools() {
|
||||
let config = test_config();
|
||||
let model_info =
|
||||
ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::PsRepl);
|
||||
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
features: &features,
|
||||
web_search_mode: Some(WebSearchMode::Cached),
|
||||
session_source: SessionSource::Cli,
|
||||
});
|
||||
let (tools, _) = build_specs(&tools_config, None, None, &[]).build();
|
||||
assert_contains_tool_names(&tools, &["ps_repl", "ps_repl_reset"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ps_repl_freeform_grammar_mentions_pragma_and_ps_source() {
|
||||
let ToolSpec::Freeform(FreeformTool { format, .. }) = create_ps_repl_tool() else {
|
||||
panic!("ps_repl should use a freeform tool spec");
|
||||
};
|
||||
|
||||
assert_eq!(format.syntax, "lark");
|
||||
assert!(format.definition.contains("PRAGMA_LINE"));
|
||||
assert!(format.definition.contains("PS_SOURCE"));
|
||||
assert!(format.definition.contains("codex-ps-repl:"));
|
||||
assert!(!format.definition.contains("(?!"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn js_repl_enabled_adds_tools() {
|
||||
let config = test_config();
|
||||
|
||||
@@ -97,6 +97,7 @@ mod personality;
|
||||
mod personality_migration;
|
||||
mod plugins;
|
||||
mod prompt_caching;
|
||||
mod ps_repl;
|
||||
mod quota_exceeded;
|
||||
mod read_file;
|
||||
mod realtime_conversation;
|
||||
|
||||
484
codex-rs/core/tests/suite/ps_repl.rs
Normal file
484
codex-rs/core/tests/suite/ps_repl.rs
Normal file
@@ -0,0 +1,484 @@
|
||||
#![allow(clippy::expect_used, clippy::unwrap_used)]
|
||||
|
||||
use anyhow::Result;
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use codex_core::features::Feature;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use core_test_support::responses;
|
||||
use core_test_support::responses::ResponseMock;
|
||||
use core_test_support::responses::ResponsesRequest;
|
||||
use core_test_support::responses::ev_assistant_message;
|
||||
use core_test_support::responses::ev_completed;
|
||||
use core_test_support::responses::ev_custom_tool_call;
|
||||
use core_test_support::responses::ev_response_created;
|
||||
use core_test_support::responses::sse;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_event_match;
|
||||
use serde_json::Value;
|
||||
use std::fs;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::Path;
|
||||
use tempfile::tempdir;
|
||||
use wiremock::MockServer;
|
||||
|
||||
fn custom_tool_output_text_and_success(
|
||||
req: &ResponsesRequest,
|
||||
call_id: &str,
|
||||
) -> (String, Option<bool>) {
|
||||
let (output, success) = req
|
||||
.custom_tool_call_output_content_and_success(call_id)
|
||||
.expect("custom tool output should be present");
|
||||
(output.unwrap_or_default(), success)
|
||||
}
|
||||
|
||||
fn tool_names(body: &serde_json::Value) -> Vec<String> {
|
||||
body["tools"]
|
||||
.as_array()
|
||||
.expect("tools array should be present")
|
||||
.iter()
|
||||
.map(|tool| {
|
||||
tool.get("name")
|
||||
.and_then(|value| value.as_str())
|
||||
.or_else(|| tool.get("type").and_then(|value| value.as_str()))
|
||||
.expect("tool should have a name or type")
|
||||
.to_string()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn write_too_old_pwsh_script(dir: &Path) -> Result<std::path::PathBuf> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let path = dir.join("old-pwsh.cmd");
|
||||
fs::write(&path, "@echo off\r\necho PowerShell 5.1.0\r\n")?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let path = dir.join("old-pwsh.sh");
|
||||
fs::write(&path, "#!/bin/sh\necho PowerShell 5.1.0\n")?;
|
||||
let mut permissions = fs::metadata(&path)?.permissions();
|
||||
permissions.set_mode(0o755);
|
||||
fs::set_permissions(&path, permissions)?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
{
|
||||
anyhow::bail!("unsupported platform for ps_repl test fixture");
|
||||
}
|
||||
}
|
||||
|
||||
fn write_test_png(dir: &Path) -> Result<std::path::PathBuf> {
|
||||
let path = dir.join("dot.png");
|
||||
let png_bytes = BASE64_STANDARD.decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==",
|
||||
)?;
|
||||
fs::write(&path, png_bytes)?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn ps_single_quote(input: &Path) -> String {
|
||||
input.display().to_string().replace('\'', "''")
|
||||
}
|
||||
|
||||
async fn run_ps_repl_turn(
|
||||
server: &MockServer,
|
||||
prompt: &str,
|
||||
calls: &[(&str, &str)],
|
||||
) -> Result<ResponseMock> {
|
||||
let test = test_codex()
|
||||
.with_config(|config| {
|
||||
config.features.enable(Feature::PsRepl);
|
||||
})
|
||||
.build(server)
|
||||
.await?;
|
||||
|
||||
let mut first_events = vec![ev_response_created("resp-1")];
|
||||
for (call_id, ps_input) in calls {
|
||||
first_events.push(ev_custom_tool_call(call_id, "ps_repl", ps_input));
|
||||
}
|
||||
first_events.push(ev_completed("resp-1"));
|
||||
responses::mount_sse_once(server, sse(first_events)).await;
|
||||
|
||||
let second_mock = responses::mount_sse_once(
|
||||
server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
test.submit_turn(prompt).await?;
|
||||
Ok(second_mock)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn ps_repl_is_not_advertised_when_startup_pwsh_is_incompatible() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
if std::env::var_os("CODEX_PS_REPL_PATH").is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let temp = tempdir()?;
|
||||
let old_pwsh = write_too_old_pwsh_script(temp.path())?;
|
||||
|
||||
let test = test_codex()
|
||||
.with_config(move |config| {
|
||||
config.features.enable(Feature::PsRepl);
|
||||
config.ps_repl_path = Some(old_pwsh);
|
||||
})
|
||||
.build(&server)
|
||||
.await?;
|
||||
let warning = wait_for_event_match(&test.codex, |event| match event {
|
||||
EventMsg::Warning(ev) if ev.message.contains("Disabled `ps_repl` for this session") => {
|
||||
Some(ev.message.clone())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
assert!(
|
||||
warning.contains("PowerShell runtime"),
|
||||
"warning should explain the PowerShell compatibility issue: {warning}"
|
||||
);
|
||||
|
||||
let request_mock = responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
test.submit_turn("hello").await?;
|
||||
|
||||
let body = request_mock.single_request().body_json();
|
||||
let tools = tool_names(&body);
|
||||
assert!(
|
||||
!tools.iter().any(|tool| tool == "ps_repl"),
|
||||
"ps_repl should be omitted when startup validation fails: {tools:?}"
|
||||
);
|
||||
assert!(
|
||||
!tools.iter().any(|tool| tool == "ps_repl_reset"),
|
||||
"ps_repl_reset should be omitted when startup validation fails: {tools:?}"
|
||||
);
|
||||
let instructions = body["instructions"].as_str().unwrap_or_default();
|
||||
assert!(
|
||||
!instructions.contains("## PowerShell REPL (pwsh)"),
|
||||
"startup instructions should not mention ps_repl when it is disabled: {instructions}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn ps_repl_persists_variables_functions_and_modules() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let test = test_codex()
|
||||
.with_config(|config| {
|
||||
config.features.enable(Feature::PsRepl);
|
||||
})
|
||||
.build(&server)
|
||||
.await?;
|
||||
|
||||
responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_custom_tool_call(
|
||||
"call-1",
|
||||
"ps_repl",
|
||||
r#"
|
||||
$x = 41
|
||||
New-Module -Name CodexPsTestModule -ScriptBlock {
|
||||
function Get-CodexValue { 42 }
|
||||
} | Import-Module
|
||||
function Add-One {
|
||||
param([int]$Value)
|
||||
$Value + 1
|
||||
}
|
||||
Write-Output "state-ready"
|
||||
"#,
|
||||
),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let second_mock = responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-2"),
|
||||
ev_custom_tool_call(
|
||||
"call-2",
|
||||
"ps_repl",
|
||||
r#"
|
||||
Write-Output ($x + 1)
|
||||
Write-Output (Add-One -Value 1)
|
||||
Write-Output (Get-CodexValue)
|
||||
"#,
|
||||
),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let third_mock = responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-3"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
test.submit_turn("run ps_repl twice").await?;
|
||||
|
||||
let req2 = second_mock.single_request();
|
||||
let (first_output, first_success) = custom_tool_output_text_and_success(&req2, "call-1");
|
||||
assert_ne!(
|
||||
first_success,
|
||||
Some(false),
|
||||
"first ps_repl call failed unexpectedly: {first_output}"
|
||||
);
|
||||
assert!(first_output.contains("state-ready"));
|
||||
|
||||
let req3 = third_mock.single_request();
|
||||
let (second_output, second_success) = custom_tool_output_text_and_success(&req3, "call-2");
|
||||
assert_ne!(
|
||||
second_success,
|
||||
Some(false),
|
||||
"second ps_repl call failed unexpectedly: {second_output}"
|
||||
);
|
||||
let lines = second_output
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
assert!(
|
||||
lines.iter().filter(|line| **line == "42").count() >= 2,
|
||||
"expected persisted variable and module output, got: {second_output}"
|
||||
);
|
||||
assert!(
|
||||
lines.contains(&"2"),
|
||||
"expected persisted function output, got: {second_output}"
|
||||
);
|
||||
assert!(
|
||||
!second_output.contains("Get-CodexValue"),
|
||||
"unexpected formatting leak: {second_output}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn ps_repl_can_invoke_builtin_tools() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let mock = run_ps_repl_turn(
|
||||
&server,
|
||||
"use ps_repl to call a tool",
|
||||
&[(
|
||||
"call-1",
|
||||
"$toolOut = Invoke-CodexTool -Name list_mcp_resources -Arguments @{}; Write-Output $toolOut.type",
|
||||
)],
|
||||
)
|
||||
.await?;
|
||||
|
||||
let req = mock.single_request();
|
||||
let (output, success) = custom_tool_output_text_and_success(&req, "call-1");
|
||||
assert_ne!(
|
||||
success,
|
||||
Some(false),
|
||||
"ps_repl call failed unexpectedly: {output}"
|
||||
);
|
||||
assert!(output.contains("function_call_output"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn ps_repl_tool_call_rejects_recursive_ps_repl_invocation() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let mock = run_ps_repl_turn(
|
||||
&server,
|
||||
"use ps_repl recursively",
|
||||
&[(
|
||||
"call-1",
|
||||
r#"
|
||||
try {
|
||||
Invoke-CodexTool -Name ps_repl -Arguments "Write-Output 'recursive'" | Out-Null
|
||||
Write-Output "unexpected-success"
|
||||
} catch {
|
||||
Write-Output $_.Exception.Message
|
||||
}
|
||||
"#,
|
||||
)],
|
||||
)
|
||||
.await?;
|
||||
|
||||
let req = mock.single_request();
|
||||
let (output, success) = custom_tool_output_text_and_success(&req, "call-1");
|
||||
assert_ne!(
|
||||
success,
|
||||
Some(false),
|
||||
"ps_repl call failed unexpectedly: {output}"
|
||||
);
|
||||
assert!(
|
||||
output.contains("ps_repl cannot invoke itself"),
|
||||
"expected recursion guard message, got output: {output}"
|
||||
);
|
||||
assert!(
|
||||
!output.contains("unexpected-success"),
|
||||
"recursive ps_repl call unexpectedly succeeded: {output}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn ps_repl_resets_after_timeout_and_accepts_followup_execution() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let test = test_codex()
|
||||
.with_config(|config| {
|
||||
config.features.enable(Feature::PsRepl);
|
||||
})
|
||||
.build(&server)
|
||||
.await?;
|
||||
|
||||
responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_custom_tool_call(
|
||||
"call-1",
|
||||
"ps_repl",
|
||||
"# codex-ps-repl: timeout_ms=50\nStart-Sleep -Milliseconds 500",
|
||||
),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let second_mock = responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-2"),
|
||||
ev_custom_tool_call("call-2", "ps_repl", "Write-Output 'healthy'"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let third_mock = responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-3"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
test.submit_turn("run ps_repl after timeout").await?;
|
||||
|
||||
let req2 = second_mock.single_request();
|
||||
let (first_output, first_success) = custom_tool_output_text_and_success(&req2, "call-1");
|
||||
assert_ne!(
|
||||
first_success,
|
||||
Some(true),
|
||||
"timeout should not report success: {first_output}"
|
||||
);
|
||||
assert!(
|
||||
first_output.contains("ps_repl execution timed out"),
|
||||
"expected timeout output, got: {first_output}"
|
||||
);
|
||||
|
||||
let req3 = third_mock.single_request();
|
||||
let (second_output, second_success) = custom_tool_output_text_and_success(&req3, "call-2");
|
||||
assert_ne!(
|
||||
second_success,
|
||||
Some(false),
|
||||
"ps_repl follow-up execution failed unexpectedly: {second_output}"
|
||||
);
|
||||
assert!(second_output.contains("healthy"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn ps_repl_captures_standard_powershell_output_streams() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let mock = run_ps_repl_turn(
|
||||
&server,
|
||||
"capture powershell output",
|
||||
&[(
|
||||
"call-1",
|
||||
"Write-Output 'stdout'; Write-Warning 'warn-stream'",
|
||||
)],
|
||||
)
|
||||
.await?;
|
||||
|
||||
let req = mock.single_request();
|
||||
let (output, success) = custom_tool_output_text_and_success(&req, "call-1");
|
||||
assert_ne!(
|
||||
success,
|
||||
Some(false),
|
||||
"ps_repl call failed unexpectedly: {output}"
|
||||
);
|
||||
assert!(output.contains("stdout"));
|
||||
assert!(output.contains("warn-stream"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn ps_repl_view_image_propagates_content_items() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let temp = tempdir()?;
|
||||
let png_path = write_test_png(temp.path())?;
|
||||
let png_path = ps_single_quote(&png_path);
|
||||
let script =
|
||||
format!("$null = Invoke-CodexTool -Name view_image -Arguments @{{ path = '{png_path}' }}");
|
||||
|
||||
let mock = run_ps_repl_turn(
|
||||
&server,
|
||||
"render an image via ps_repl",
|
||||
&[("call-1", &script)],
|
||||
)
|
||||
.await?;
|
||||
|
||||
let req = mock.single_request();
|
||||
let custom_output = req.custom_tool_call_output("call-1");
|
||||
let output_items = custom_output
|
||||
.get("output")
|
||||
.and_then(Value::as_array)
|
||||
.expect("custom_tool_call_output should be a content item array");
|
||||
let image_url = output_items
|
||||
.iter()
|
||||
.find_map(|item| {
|
||||
(item.get("type").and_then(Value::as_str) == Some("input_image"))
|
||||
.then(|| item.get("image_url").and_then(Value::as_str))
|
||||
.flatten()
|
||||
})
|
||||
.expect("image_url present in ps_repl custom tool output");
|
||||
assert!(
|
||||
image_url.starts_with("data:image/png;base64,"),
|
||||
"expected png data URL, got {image_url}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -277,6 +277,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result
|
||||
model_provider: model_provider.clone(),
|
||||
codex_linux_sandbox_exe: arg0_paths.codex_linux_sandbox_exe.clone(),
|
||||
main_execve_wrapper_exe: arg0_paths.main_execve_wrapper_exe.clone(),
|
||||
ps_repl_path: None,
|
||||
js_repl_node_path: None,
|
||||
js_repl_node_module_dirs: None,
|
||||
zsh_path: None,
|
||||
|
||||
68
docs/ps_repl.md
Normal file
68
docs/ps_repl.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# PowerShell REPL (`ps_repl`)
|
||||
|
||||
`ps_repl` runs PowerShell in a persistent `pwsh`-backed kernel.
|
||||
|
||||
## Feature gate
|
||||
|
||||
`ps_repl` is disabled by default and only appears when:
|
||||
|
||||
```toml
|
||||
[features]
|
||||
ps_repl = true
|
||||
```
|
||||
|
||||
The initial rollout stage is under development.
|
||||
|
||||
## PowerShell runtime
|
||||
|
||||
`ps_repl` requires PowerShell 7 or newer.
|
||||
|
||||
Runtime resolution order:
|
||||
|
||||
1. `CODEX_PS_REPL_PATH` environment variable
|
||||
2. `ps_repl_path` in config/profile
|
||||
3. `pwsh` discovered on `PATH`
|
||||
|
||||
You can configure an explicit runtime path:
|
||||
|
||||
```toml
|
||||
ps_repl_path = "/absolute/path/to/pwsh"
|
||||
```
|
||||
|
||||
If only Windows PowerShell (`powershell.exe`) is available, `ps_repl` stays disabled and emits a startup warning telling you to install PowerShell 7.
|
||||
|
||||
## Usage
|
||||
|
||||
- `ps_repl` is a freeform tool: send raw PowerShell source text.
|
||||
- Optional first-line pragma:
|
||||
- `# codex-ps-repl: timeout_ms=15000`
|
||||
- Variables, functions, aliases, imported modules, environment changes, and `$LASTEXITCODE` persist across calls.
|
||||
- Use `ps_repl_reset` to clear the kernel state.
|
||||
|
||||
## Helper APIs inside the kernel
|
||||
|
||||
`ps_repl` exposes these helpers:
|
||||
|
||||
- `$CodexTmpDir`: per-session scratch directory path.
|
||||
- `Invoke-CodexTool -Name <string> -Arguments <object|string>`: executes a normal Codex tool call from inside `ps_repl`.
|
||||
- `$Codex.TmpDir`: alias for the scratch directory.
|
||||
- `$Codex.Tool(<name>, <args>)`: thin alias to `Invoke-CodexTool`.
|
||||
|
||||
`Invoke-CodexTool` returns the raw tool output object. Nested tool calls can also return multimodal content such as `view_image` results.
|
||||
|
||||
To share generated images with the model, write a file under `$CodexTmpDir`, call:
|
||||
|
||||
```powershell
|
||||
Invoke-CodexTool -Name view_image -Arguments @{ path = "/absolute/path" }
|
||||
```
|
||||
|
||||
Then delete the file.
|
||||
|
||||
## Output and transport
|
||||
|
||||
`ps_repl` uses a JSON-line transport over stdio.
|
||||
|
||||
- Safe output forms: pipeline output, `Write-Output`, `Write-Host`, `Write-Verbose`, `Write-Warning`
|
||||
- Avoid: direct `[Console]::Write*`, raw StdOut/StdErr writes, or other host-level output that bypasses PowerShell streams
|
||||
|
||||
Bypassing PowerShell streams can corrupt the transport between the Rust host and the persistent kernel.
|
||||
Reference in New Issue
Block a user