mirror of
https://github.com/openai/codex.git
synced 2026-10-21 17:48:44 +00:00
Merge branch 'main' into jif/drop-parent-fork-setup-context
This commit is contained in:
@@ -1516,6 +1516,12 @@
|
||||
"subagent_usage_hint_text": {
|
||||
"type": "string"
|
||||
},
|
||||
"tool_namespace": {
|
||||
"maxLength": 64,
|
||||
"minLength": 1,
|
||||
"pattern": "^[a-zA-Z0-9_-]+$",
|
||||
"type": "string"
|
||||
},
|
||||
"usage_hint_enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
||||
@@ -10112,6 +10112,7 @@ usage_hint_enabled = false
|
||||
usage_hint_text = "Custom delegation guidance."
|
||||
root_agent_usage_hint_text = "Root guidance."
|
||||
subagent_usage_hint_text = "Subagent guidance."
|
||||
tool_namespace = "agents"
|
||||
hide_spawn_agent_metadata = true
|
||||
non_code_mode_only = true
|
||||
"#,
|
||||
@@ -10142,6 +10143,10 @@ non_code_mode_only = true
|
||||
config.multi_agent_v2.subagent_usage_hint_text.as_deref(),
|
||||
Some("Subagent guidance.")
|
||||
);
|
||||
assert_eq!(
|
||||
config.multi_agent_v2.tool_namespace.as_deref(),
|
||||
Some("agents")
|
||||
);
|
||||
assert!(config.multi_agent_v2.hide_spawn_agent_metadata);
|
||||
assert!(config.multi_agent_v2.non_code_mode_only);
|
||||
|
||||
@@ -10164,6 +10169,7 @@ usage_hint_enabled = true
|
||||
usage_hint_text = "base hint"
|
||||
root_agent_usage_hint_text = "base root hint"
|
||||
subagent_usage_hint_text = "base subagent hint"
|
||||
tool_namespace = "base_agents"
|
||||
hide_spawn_agent_metadata = true
|
||||
non_code_mode_only = false
|
||||
|
||||
@@ -10176,6 +10182,7 @@ usage_hint_enabled = false
|
||||
usage_hint_text = "profile hint"
|
||||
root_agent_usage_hint_text = "profile root hint"
|
||||
subagent_usage_hint_text = "profile subagent hint"
|
||||
tool_namespace = "profile_agents"
|
||||
hide_spawn_agent_metadata = false
|
||||
non_code_mode_only = true
|
||||
"#,
|
||||
@@ -10204,6 +10211,10 @@ non_code_mode_only = true
|
||||
config.multi_agent_v2.subagent_usage_hint_text.as_deref(),
|
||||
Some("profile subagent hint")
|
||||
);
|
||||
assert_eq!(
|
||||
config.multi_agent_v2.tool_namespace.as_deref(),
|
||||
Some("profile_agents")
|
||||
);
|
||||
assert!(!config.multi_agent_v2.hide_spawn_agent_metadata);
|
||||
assert!(config.multi_agent_v2.non_code_mode_only);
|
||||
|
||||
@@ -10464,6 +10475,42 @@ default_wait_timeout_ms = 2500
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_agent_v2_rejects_invalid_tool_namespace() -> std::io::Result<()> {
|
||||
for (namespace, expected_message) in [
|
||||
(
|
||||
"bad namespace",
|
||||
"features.multi_agent_v2.tool_namespace must match ^[a-zA-Z0-9_-]+$",
|
||||
),
|
||||
(
|
||||
"functions",
|
||||
"features.multi_agent_v2.tool_namespace uses a reserved namespace: functions",
|
||||
),
|
||||
] {
|
||||
let codex_home = TempDir::new()?;
|
||||
std::fs::write(
|
||||
codex_home.path().join(CONFIG_TOML_FILE),
|
||||
format!(
|
||||
r#"[features.multi_agent_v2]
|
||||
enabled = true
|
||||
tool_namespace = "{namespace}"
|
||||
"#
|
||||
),
|
||||
)?;
|
||||
|
||||
let err = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
.build()
|
||||
.await
|
||||
.expect_err("invalid multi_agent_v2 tool namespace should fail");
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
|
||||
assert_eq!(err.to_string(), expected_message);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_agent_v2_session_thread_cap_one_disallows_subagents() -> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
|
||||
@@ -953,6 +953,7 @@ pub struct MultiAgentV2Config {
|
||||
pub usage_hint_text: Option<String>,
|
||||
pub root_agent_usage_hint_text: Option<String>,
|
||||
pub subagent_usage_hint_text: Option<String>,
|
||||
pub tool_namespace: Option<String>,
|
||||
pub hide_spawn_agent_metadata: bool,
|
||||
pub non_code_mode_only: bool,
|
||||
}
|
||||
@@ -969,6 +970,7 @@ impl Default for MultiAgentV2Config {
|
||||
usage_hint_text: None,
|
||||
root_agent_usage_hint_text: None,
|
||||
subagent_usage_hint_text: None,
|
||||
tool_namespace: None,
|
||||
hide_spawn_agent_metadata: false,
|
||||
non_code_mode_only: false,
|
||||
}
|
||||
@@ -2178,6 +2180,11 @@ fn resolve_multi_agent_v2_config(
|
||||
.or_else(|| base.and_then(|config| config.subagent_usage_hint_text.as_ref()))
|
||||
.cloned()
|
||||
.or(default.subagent_usage_hint_text);
|
||||
let tool_namespace = profile
|
||||
.and_then(|config| config.tool_namespace.as_ref())
|
||||
.or_else(|| base.and_then(|config| config.tool_namespace.as_ref()))
|
||||
.cloned()
|
||||
.or(default.tool_namespace);
|
||||
let hide_spawn_agent_metadata = profile
|
||||
.and_then(|config| config.hide_spawn_agent_metadata)
|
||||
.or_else(|| base.and_then(|config| config.hide_spawn_agent_metadata))
|
||||
@@ -2196,6 +2203,7 @@ fn resolve_multi_agent_v2_config(
|
||||
usage_hint_text,
|
||||
root_agent_usage_hint_text,
|
||||
subagent_usage_hint_text,
|
||||
tool_namespace,
|
||||
hide_spawn_agent_metadata,
|
||||
non_code_mode_only,
|
||||
}
|
||||
@@ -2290,6 +2298,69 @@ fn validate_multi_agent_v2_wait_timeout(label: &str, value: i64) -> std::io::Res
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_multi_agent_v2_tool_namespace(namespace: Option<&str>) -> std::io::Result<()> {
|
||||
const LABEL: &str = "features.multi_agent_v2.tool_namespace";
|
||||
const MAX_LEN: usize = 64;
|
||||
const RESERVED_RESPONSES_NAMESPACES: &[&str] = &[
|
||||
"api_tool",
|
||||
"browser",
|
||||
"computer",
|
||||
"container",
|
||||
"file_search",
|
||||
"functions",
|
||||
"image_gen",
|
||||
"multi_tool_use",
|
||||
"python",
|
||||
"python_user_visible",
|
||||
"submodel_delegator",
|
||||
"terminal",
|
||||
"tool_search",
|
||||
"web",
|
||||
];
|
||||
|
||||
let Some(namespace) = namespace else {
|
||||
return Ok(());
|
||||
};
|
||||
if namespace.is_empty() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("{LABEL} must not be empty"),
|
||||
));
|
||||
}
|
||||
if namespace.trim() != namespace {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("{LABEL} must not have leading or trailing whitespace"),
|
||||
));
|
||||
}
|
||||
if !namespace
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
|
||||
{
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("{LABEL} must match ^[a-zA-Z0-9_-]+$"),
|
||||
));
|
||||
}
|
||||
if namespace.chars().count() > MAX_LEN {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("{LABEL} must be at most {MAX_LEN} characters"),
|
||||
));
|
||||
}
|
||||
if namespace == "mcp"
|
||||
|| namespace.starts_with("mcp__")
|
||||
|| RESERVED_RESPONSES_NAMESPACES.contains(&namespace)
|
||||
{
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("{LABEL} uses a reserved namespace: {namespace}"),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl Config {
|
||||
#[cfg(test)]
|
||||
async fn load_from_base_config_with_overrides(
|
||||
@@ -2911,6 +2982,7 @@ impl Config {
|
||||
"features.multi_agent_v2.default_wait_timeout_ms must be at most features.multi_agent_v2.max_wait_timeout_ms",
|
||||
));
|
||||
}
|
||||
validate_multi_agent_v2_tool_namespace(multi_agent_v2.tool_namespace.as_deref())?;
|
||||
let agent_max_threads_from_config = cfg.agents.as_ref().and_then(|agents| agents.max_threads);
|
||||
let agent_max_threads = if features.enabled(Feature::MultiAgentV2) {
|
||||
if agent_max_threads_from_config.is_some() {
|
||||
|
||||
@@ -56,6 +56,7 @@ pub(super) async fn spawn_review_thread(
|
||||
.with_spawn_agent_usage_hint(config.multi_agent_v2.usage_hint_enabled)
|
||||
.with_spawn_agent_usage_hint_text(config.multi_agent_v2.usage_hint_text.clone())
|
||||
.with_hide_spawn_agent_metadata(config.multi_agent_v2.hide_spawn_agent_metadata)
|
||||
.with_multi_agent_v2_tool_namespace(config.multi_agent_v2.tool_namespace.clone())
|
||||
.with_multi_agent_v2_non_code_mode_only(config.multi_agent_v2.non_code_mode_only)
|
||||
.with_goal_tools_allowed(goal_tools_supported)
|
||||
.with_max_concurrent_threads_per_session(config.agent_max_threads)
|
||||
|
||||
@@ -220,6 +220,7 @@ impl TurnContext {
|
||||
.with_spawn_agent_usage_hint(config.multi_agent_v2.usage_hint_enabled)
|
||||
.with_spawn_agent_usage_hint_text(config.multi_agent_v2.usage_hint_text.clone())
|
||||
.with_hide_spawn_agent_metadata(config.multi_agent_v2.hide_spawn_agent_metadata)
|
||||
.with_multi_agent_v2_tool_namespace(config.multi_agent_v2.tool_namespace.clone())
|
||||
.with_multi_agent_v2_non_code_mode_only(config.multi_agent_v2.non_code_mode_only)
|
||||
.with_goal_tools_allowed(self.tools_config.goal_tools)
|
||||
.with_max_concurrent_threads_per_session(
|
||||
@@ -539,6 +540,7 @@ impl Session {
|
||||
.with_spawn_agent_usage_hint(per_turn_config.multi_agent_v2.usage_hint_enabled)
|
||||
.with_spawn_agent_usage_hint_text(per_turn_config.multi_agent_v2.usage_hint_text.clone())
|
||||
.with_hide_spawn_agent_metadata(per_turn_config.multi_agent_v2.hide_spawn_agent_metadata)
|
||||
.with_multi_agent_v2_tool_namespace(per_turn_config.multi_agent_v2.tool_namespace.clone())
|
||||
.with_multi_agent_v2_non_code_mode_only(per_turn_config.multi_agent_v2.non_code_mode_only)
|
||||
.with_goal_tools_allowed(goal_tools_supported)
|
||||
.with_max_concurrent_threads_per_session(
|
||||
|
||||
@@ -3,7 +3,6 @@ use codex_apply_patch::MaybeApplyPatchVerified;
|
||||
use codex_exec_server::LOCAL_FS;
|
||||
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
||||
use codex_protocol::protocol::FileChange;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use core_test_support::PathBufExt;
|
||||
use core_test_support::PathExt;
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -251,12 +250,11 @@ fn write_permissions_for_paths_skip_dirs_already_writable_under_workspace_root()
|
||||
std::fs::create_dir_all(&nested).expect("create nested dir");
|
||||
let file_path = AbsolutePathBuf::try_from(nested.join("file.txt"))
|
||||
.expect("nested file path should be absolute");
|
||||
let sandbox_policy = FileSystemSandboxPolicy::from(&SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![],
|
||||
network_access: false,
|
||||
exclude_tmpdir_env_var: true,
|
||||
exclude_slash_tmp: false,
|
||||
});
|
||||
let sandbox_policy = FileSystemSandboxPolicy::workspace_write(
|
||||
&[],
|
||||
/*exclude_tmpdir_env_var*/ true,
|
||||
/*exclude_slash_tmp*/ false,
|
||||
);
|
||||
|
||||
let permissions = write_permissions_for_paths(&[file_path], &sandbox_policy, &cwd);
|
||||
|
||||
@@ -273,12 +271,11 @@ fn write_permissions_for_paths_keep_dirs_outside_workspace_root() {
|
||||
let file_path = AbsolutePathBuf::try_from(outside.join("file.txt"))
|
||||
.expect("outside file path should be absolute");
|
||||
let cwd_abs = cwd.abs();
|
||||
let sandbox_policy = FileSystemSandboxPolicy::from(&SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![],
|
||||
network_access: false,
|
||||
exclude_tmpdir_env_var: true,
|
||||
exclude_slash_tmp: true,
|
||||
});
|
||||
let sandbox_policy = FileSystemSandboxPolicy::workspace_write(
|
||||
&[],
|
||||
/*exclude_tmpdir_env_var*/ true,
|
||||
/*exclude_slash_tmp*/ true,
|
||||
);
|
||||
|
||||
let permissions = write_permissions_for_paths(&[file_path], &sandbox_policy, &cwd_abs);
|
||||
let expected_outside =
|
||||
|
||||
@@ -281,10 +281,6 @@ impl ToolRegistry {
|
||||
self.tools.get(name).map(Arc::clone)
|
||||
}
|
||||
|
||||
pub(crate) fn tool_exposure(&self, name: &ToolName) -> Option<ToolExposure> {
|
||||
self.tools.get(name).map(|tool| tool.exposure())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn has_tool(&self, name: &ToolName) -> bool {
|
||||
self.tool(name).is_some()
|
||||
|
||||
@@ -7,7 +7,6 @@ use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
||||
use codex_protocol::permissions::NetworkSandboxPolicy;
|
||||
use codex_protocol::protocol::GranularApprovalConfig;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_sandboxing::SandboxManager;
|
||||
use codex_sandboxing::SandboxType;
|
||||
use codex_sandboxing::policy_transforms::effective_file_system_sandbox_policy;
|
||||
@@ -201,8 +200,7 @@ async fn file_system_sandbox_context_uses_active_attempt() {
|
||||
additional_permissions: Some(additional_permissions.clone()),
|
||||
permissions_preapproved: false,
|
||||
};
|
||||
let sandbox_policy = SandboxPolicy::new_read_only_policy();
|
||||
let file_system_policy = FileSystemSandboxPolicy::from(&sandbox_policy);
|
||||
let file_system_policy = FileSystemSandboxPolicy::default();
|
||||
let permissions = PermissionProfile::from_runtime_permissions(
|
||||
&file_system_policy,
|
||||
NetworkSandboxPolicy::Restricted,
|
||||
|
||||
@@ -19,8 +19,6 @@ use codex_protocol::permissions::FileSystemSandboxKind;
|
||||
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
#[cfg(test)]
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_sandboxing::SandboxCommand;
|
||||
use codex_sandboxing::SandboxManager;
|
||||
use codex_sandboxing::SandboxTransformError;
|
||||
|
||||
@@ -5,7 +5,6 @@ use codex_protocol::permissions::FileSystemAccessMode;
|
||||
use codex_protocol::permissions::FileSystemPath;
|
||||
use codex_protocol::permissions::FileSystemSandboxEntry;
|
||||
use codex_protocol::protocol::GranularApprovalConfig;
|
||||
use codex_protocol::protocol::NetworkAccess;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
|
||||
@@ -39,13 +38,10 @@ fn bash_permission_request_payload_includes_description_when_present() {
|
||||
|
||||
#[test]
|
||||
fn external_sandbox_skips_exec_approval_on_request() {
|
||||
let sandbox_policy = SandboxPolicy::ExternalSandbox {
|
||||
network_access: NetworkAccess::Restricted,
|
||||
};
|
||||
assert_eq!(
|
||||
default_exec_approval_requirement(
|
||||
AskForApproval::OnRequest,
|
||||
&FileSystemSandboxPolicy::from(&sandbox_policy),
|
||||
&FileSystemSandboxPolicy::external_sandbox(),
|
||||
),
|
||||
ExecApprovalRequirement::Skip {
|
||||
bypass_sandbox: false,
|
||||
@@ -56,11 +52,10 @@ fn external_sandbox_skips_exec_approval_on_request() {
|
||||
|
||||
#[test]
|
||||
fn restricted_sandbox_requires_exec_approval_on_request() {
|
||||
let sandbox_policy = SandboxPolicy::new_read_only_policy();
|
||||
assert_eq!(
|
||||
default_exec_approval_requirement(
|
||||
AskForApproval::OnRequest,
|
||||
&FileSystemSandboxPolicy::from(&sandbox_policy)
|
||||
&FileSystemSandboxPolicy::default()
|
||||
),
|
||||
ExecApprovalRequirement::NeedsApproval {
|
||||
reason: None,
|
||||
@@ -79,9 +74,8 @@ fn default_exec_approval_requirement_rejects_sandbox_prompt_when_granular_disabl
|
||||
mcp_elicitations: true,
|
||||
});
|
||||
|
||||
let sandbox_policy = SandboxPolicy::new_read_only_policy();
|
||||
let requirement =
|
||||
default_exec_approval_requirement(policy, &FileSystemSandboxPolicy::from(&sandbox_policy));
|
||||
default_exec_approval_requirement(policy, &FileSystemSandboxPolicy::default());
|
||||
|
||||
assert_eq!(
|
||||
requirement,
|
||||
@@ -101,9 +95,8 @@ fn default_exec_approval_requirement_keeps_prompt_when_granular_allows_sandbox_a
|
||||
mcp_elicitations: false,
|
||||
});
|
||||
|
||||
let sandbox_policy = SandboxPolicy::new_read_only_policy();
|
||||
let requirement =
|
||||
default_exec_approval_requirement(policy, &FileSystemSandboxPolicy::from(&sandbox_policy));
|
||||
default_exec_approval_requirement(policy, &FileSystemSandboxPolicy::default());
|
||||
|
||||
assert_eq!(
|
||||
requirement,
|
||||
|
||||
@@ -2,6 +2,7 @@ use crate::config::DEFAULT_MULTI_AGENT_V2_DEFAULT_WAIT_TIMEOUT_MS;
|
||||
use crate::config::DEFAULT_MULTI_AGENT_V2_MAX_WAIT_TIMEOUT_MS;
|
||||
use crate::config::DEFAULT_MULTI_AGENT_V2_MIN_WAIT_TIMEOUT_MS;
|
||||
use crate::tools::code_mode::execute_spec::create_code_mode_tool;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::handlers::ApplyPatchHandler;
|
||||
use crate::tools::handlers::CodeModeExecuteHandler;
|
||||
use crate::tools::handlers::CodeModeWaitHandler;
|
||||
@@ -58,12 +59,14 @@ use codex_mcp::ToolInfo;
|
||||
use codex_protocol::dynamic_tools::DynamicToolSpec;
|
||||
use codex_protocol::openai_models::ConfigShellToolType;
|
||||
use codex_tools::DiscoverableTool;
|
||||
use codex_tools::ResponsesApiNamespace;
|
||||
use codex_tools::ResponsesApiNamespaceTool;
|
||||
use codex_tools::TOOL_SEARCH_TOOL_NAME;
|
||||
use codex_tools::ToolCall as ExtensionToolCall;
|
||||
use codex_tools::ToolEnvironmentMode;
|
||||
use codex_tools::ToolExecutor;
|
||||
use codex_tools::ToolName;
|
||||
use codex_tools::ToolOutput;
|
||||
use codex_tools::ToolSpec;
|
||||
use codex_tools::ToolsConfig;
|
||||
use codex_tools::collect_code_mode_exec_prompt_tool_definitions;
|
||||
@@ -73,6 +76,8 @@ use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use tracing::warn;
|
||||
|
||||
const MULTI_AGENT_V2_NAMESPACE_DESCRIPTION: &str = "Tools for spawning and managing sub-agents.";
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct ToolRegistryBuildParams<'a> {
|
||||
mcp_tools: Option<&'a [ToolInfo]>,
|
||||
@@ -127,22 +132,29 @@ fn build_model_visible_specs_and_registry(
|
||||
let mut specs = Vec::new();
|
||||
let mut seen_tool_names = HashSet::new();
|
||||
for executor in &executors {
|
||||
if !seen_tool_names.insert(executor.tool_name()) {
|
||||
let tool_name = executor.tool_name();
|
||||
if !seen_tool_names.insert(tool_name.clone()) {
|
||||
continue;
|
||||
}
|
||||
if executor.exposure().is_direct()
|
||||
let exposure = executor.exposure();
|
||||
if exposure.is_direct()
|
||||
&& !is_hidden_by_code_mode_only(config, &tool_name, exposure)
|
||||
&& let Some(spec) = executor.spec()
|
||||
{
|
||||
specs.push(spec_for_model_request(config, executor.exposure(), spec));
|
||||
specs.push(spec_for_model_request(config, exposure, spec));
|
||||
}
|
||||
}
|
||||
for spec in hosted_specs {
|
||||
if !is_hidden_by_code_mode_only(config, &ToolName::plain(spec.name()), ToolExposure::Direct)
|
||||
{
|
||||
specs.push(spec);
|
||||
}
|
||||
}
|
||||
specs.extend(hosted_specs);
|
||||
|
||||
let registry = ToolRegistry::from_tools(executors);
|
||||
let model_visible_specs = merge_into_namespaces(specs)
|
||||
.into_iter()
|
||||
.filter(|spec| config.namespace_tools || !matches!(spec, ToolSpec::Namespace(_)))
|
||||
.filter(|spec| !is_hidden_by_code_mode_only(config, ®istry, spec))
|
||||
.collect();
|
||||
|
||||
(model_visible_specs, registry)
|
||||
@@ -210,17 +222,14 @@ fn agent_type_description(config: &ToolsConfig, default_agent_type_description:
|
||||
|
||||
fn is_hidden_by_code_mode_only(
|
||||
config: &ToolsConfig,
|
||||
registry: &ToolRegistry,
|
||||
spec: &ToolSpec,
|
||||
tool_name: &ToolName,
|
||||
exposure: ToolExposure,
|
||||
) -> bool {
|
||||
if !config.code_mode_only_enabled || !codex_code_mode::is_code_mode_nested_tool(spec.name()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let exposure = registry
|
||||
.tool_exposure(&ToolName::plain(spec.name()))
|
||||
.unwrap_or(ToolExposure::Direct);
|
||||
exposure != ToolExposure::DirectModelOnly
|
||||
config.code_mode_only_enabled
|
||||
&& exposure != ToolExposure::DirectModelOnly
|
||||
&& codex_code_mode::is_code_mode_nested_tool(&codex_tools::code_mode_name_for_tool_name(
|
||||
tool_name,
|
||||
))
|
||||
}
|
||||
|
||||
fn build_code_mode_executors(
|
||||
@@ -442,6 +451,10 @@ fn collect_tool_executors(
|
||||
} else {
|
||||
ToolExposure::Direct
|
||||
};
|
||||
let tool_namespace = config
|
||||
.namespace_tools
|
||||
.then_some(config.multi_agent_v2_tool_namespace.as_deref())
|
||||
.flatten();
|
||||
let agent_type_description =
|
||||
agent_type_description(config, params.default_agent_type_description);
|
||||
executors.push(multi_agent_v2_handler(
|
||||
@@ -454,15 +467,33 @@ fn collect_tool_executors(
|
||||
max_concurrent_threads_per_session: config.max_concurrent_threads_per_session,
|
||||
}),
|
||||
exposure,
|
||||
tool_namespace,
|
||||
));
|
||||
executors.push(multi_agent_v2_handler(
|
||||
SendMessageHandlerV2,
|
||||
exposure,
|
||||
tool_namespace,
|
||||
));
|
||||
executors.push(multi_agent_v2_handler(
|
||||
FollowupTaskHandlerV2,
|
||||
exposure,
|
||||
tool_namespace,
|
||||
));
|
||||
executors.push(multi_agent_v2_handler(SendMessageHandlerV2, exposure));
|
||||
executors.push(multi_agent_v2_handler(FollowupTaskHandlerV2, exposure));
|
||||
executors.push(multi_agent_v2_handler(
|
||||
WaitAgentHandlerV2::new(params.wait_agent_timeouts),
|
||||
exposure,
|
||||
tool_namespace,
|
||||
));
|
||||
executors.push(multi_agent_v2_handler(
|
||||
CloseAgentHandlerV2,
|
||||
exposure,
|
||||
tool_namespace,
|
||||
));
|
||||
executors.push(multi_agent_v2_handler(
|
||||
ListAgentsHandlerV2,
|
||||
exposure,
|
||||
tool_namespace,
|
||||
));
|
||||
executors.push(multi_agent_v2_handler(CloseAgentHandlerV2, exposure));
|
||||
executors.push(multi_agent_v2_handler(ListAgentsHandlerV2, exposure));
|
||||
} else {
|
||||
let agent_type_description =
|
||||
agent_type_description(config, params.default_agent_type_description);
|
||||
@@ -592,8 +623,70 @@ fn append_extension_tool_executors(
|
||||
fn multi_agent_v2_handler(
|
||||
handler: impl CoreToolRuntime + 'static,
|
||||
exposure: ToolExposure,
|
||||
namespace: Option<&str>,
|
||||
) -> Arc<dyn CoreToolRuntime> {
|
||||
override_tool_exposure(Arc::new(handler), exposure)
|
||||
let handler: Arc<dyn CoreToolRuntime> = match namespace {
|
||||
Some(namespace) => Arc::new(MultiAgentV2NamespaceOverride {
|
||||
handler: Arc::new(handler),
|
||||
namespace: namespace.to_string(),
|
||||
}),
|
||||
None => Arc::new(handler),
|
||||
};
|
||||
override_tool_exposure(handler, exposure)
|
||||
}
|
||||
|
||||
struct MultiAgentV2NamespaceOverride {
|
||||
handler: Arc<dyn CoreToolRuntime>,
|
||||
namespace: String,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ToolExecutor<ToolInvocation> for MultiAgentV2NamespaceOverride {
|
||||
fn tool_name(&self) -> ToolName {
|
||||
ToolName::namespaced(self.namespace.clone(), self.handler.tool_name().name)
|
||||
}
|
||||
|
||||
fn spec(&self) -> Option<ToolSpec> {
|
||||
match self.handler.spec()? {
|
||||
ToolSpec::Function(tool) => Some(ToolSpec::Namespace(ResponsesApiNamespace {
|
||||
name: self.namespace.clone(),
|
||||
description: MULTI_AGENT_V2_NAMESPACE_DESCRIPTION.to_string(),
|
||||
tools: vec![ResponsesApiNamespaceTool::Function(tool)],
|
||||
})),
|
||||
spec => Some(spec),
|
||||
}
|
||||
}
|
||||
|
||||
fn exposure(&self) -> ToolExposure {
|
||||
self.handler.exposure()
|
||||
}
|
||||
|
||||
fn supports_parallel_tool_calls(&self) -> bool {
|
||||
self.handler.supports_parallel_tool_calls()
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
&self,
|
||||
invocation: ToolInvocation,
|
||||
) -> Result<Box<dyn ToolOutput>, codex_tools::FunctionCallError> {
|
||||
self.handler.handle(invocation).await
|
||||
}
|
||||
}
|
||||
|
||||
impl CoreToolRuntime for MultiAgentV2NamespaceOverride {
|
||||
fn matches_kind(&self, payload: &crate::tools::context::ToolPayload) -> bool {
|
||||
self.handler.matches_kind(payload)
|
||||
}
|
||||
|
||||
fn search_info(&self) -> Option<crate::tools::tool_search_entry::ToolSearchInfo> {
|
||||
self.handler.search_info()
|
||||
}
|
||||
|
||||
fn create_diff_consumer(
|
||||
&self,
|
||||
) -> Option<Box<dyn crate::tools::registry::ToolArgumentDiffConsumer>> {
|
||||
self.handler.create_diff_consumer()
|
||||
}
|
||||
}
|
||||
|
||||
fn compare_code_mode_tools(
|
||||
|
||||
@@ -1341,3 +1341,67 @@ async fn code_mode_only_can_expose_multi_agent_v2_as_normal_tools() {
|
||||
};
|
||||
assert!(!spawn_agent.description.contains("exec tool declaration"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn code_mode_only_can_expose_namespaced_multi_agent_v2_as_normal_tools() {
|
||||
let config = test_config().await;
|
||||
let model_info = construct_model_info_offline("gpt-5.4", &config);
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::CodeMode);
|
||||
features.enable(Feature::CodeModeOnly);
|
||||
features.enable(Feature::MultiAgentV2);
|
||||
let available_models = Vec::new();
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
available_models: &available_models,
|
||||
features: &features,
|
||||
image_generation_tool_auth_allowed: true,
|
||||
web_search_mode: Some(WebSearchMode::Live),
|
||||
session_source: SessionSource::Cli,
|
||||
permission_profile: &PermissionProfile::Disabled,
|
||||
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
||||
})
|
||||
.with_multi_agent_v2_tool_namespace(Some("agents".to_string()))
|
||||
.with_multi_agent_v2_non_code_mode_only(/*multi_agent_v2_non_code_mode_only*/ true);
|
||||
let router = ToolRouter::from_config(
|
||||
&tools_config,
|
||||
ToolRouterParams {
|
||||
mcp_tools: None,
|
||||
deferred_mcp_tools: None,
|
||||
discoverable_tools: None,
|
||||
extension_tool_executors: Vec::new(),
|
||||
dynamic_tools: &[],
|
||||
},
|
||||
);
|
||||
let model_visible_specs = router.model_visible_specs();
|
||||
let tool_names = model_visible_specs
|
||||
.iter()
|
||||
.map(ToolSpec::name)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(tool_names, vec!["exec", "wait", "agents"]);
|
||||
|
||||
let exec = find_tool(&model_visible_specs, "exec");
|
||||
let ToolSpec::Freeform(exec) = exec else {
|
||||
panic!("exec should be a freeform tool");
|
||||
};
|
||||
assert!(!exec.description.contains("spawn_agent"));
|
||||
assert!(!exec.description.contains("wait_agent"));
|
||||
assert!(
|
||||
!exec
|
||||
.description
|
||||
.contains("do not attempt to use any other tools directly")
|
||||
);
|
||||
|
||||
for tool_name in [
|
||||
"spawn_agent",
|
||||
"send_message",
|
||||
"followup_task",
|
||||
"wait_agent",
|
||||
"close_agent",
|
||||
"list_agents",
|
||||
] {
|
||||
let tool = find_namespace_function_tool(&model_visible_specs, "agents", tool_name);
|
||||
assert!(!tool.description.contains("exec tool declaration"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -577,6 +577,77 @@ fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() {
|
||||
assert_lacks_tool_name(&tools, "resume_agent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_specs_multi_agent_v2_uses_configured_tool_namespace() {
|
||||
let model_info = model_info();
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::MultiAgentV2);
|
||||
let available_models = Vec::new();
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
available_models: &available_models,
|
||||
features: &features,
|
||||
image_generation_tool_auth_allowed: true,
|
||||
web_search_mode: Some(WebSearchMode::Cached),
|
||||
session_source: SessionSource::Cli,
|
||||
permission_profile: &PermissionProfile::Disabled,
|
||||
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
||||
})
|
||||
.with_multi_agent_v2_tool_namespace(Some("agents".to_string()));
|
||||
let (tools, registry) = build_specs(
|
||||
&tools_config,
|
||||
/*mcp_tools*/ None,
|
||||
/*deferred_mcp_tools*/ None,
|
||||
&[],
|
||||
);
|
||||
|
||||
assert_contains_tool_names(&tools, &["agents"]);
|
||||
for tool_name in [
|
||||
"spawn_agent",
|
||||
"send_message",
|
||||
"followup_task",
|
||||
"wait_agent",
|
||||
"close_agent",
|
||||
"list_agents",
|
||||
] {
|
||||
assert_lacks_tool_name(&tools, tool_name);
|
||||
assert!(registry.has_tool(&ToolName::namespaced("agents", tool_name)));
|
||||
assert!(!registry.has_tool(&ToolName::plain(tool_name)));
|
||||
assert_namespace_contains_function(&tools, "agents", tool_name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_specs_multi_agent_v2_ignores_tool_namespace_without_namespace_support() {
|
||||
let model_info = model_info();
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::MultiAgentV2);
|
||||
let available_models = Vec::new();
|
||||
let mut tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
available_models: &available_models,
|
||||
features: &features,
|
||||
image_generation_tool_auth_allowed: true,
|
||||
web_search_mode: Some(WebSearchMode::Cached),
|
||||
session_source: SessionSource::Cli,
|
||||
permission_profile: &PermissionProfile::Disabled,
|
||||
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
||||
})
|
||||
.with_multi_agent_v2_tool_namespace(Some("agents".to_string()));
|
||||
tools_config.namespace_tools = false;
|
||||
let (tools, registry) = build_specs(
|
||||
&tools_config,
|
||||
/*mcp_tools*/ None,
|
||||
/*deferred_mcp_tools*/ None,
|
||||
&[],
|
||||
);
|
||||
|
||||
assert_contains_tool_names(&tools, &["spawn_agent", "send_message", "list_agents"]);
|
||||
assert_lacks_tool_name(&tools, "agents");
|
||||
assert!(registry.has_tool(&ToolName::plain("spawn_agent")));
|
||||
assert!(!registry.has_tool(&ToolName::namespaced("agents", "spawn_agent")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_specs_multi_agent_v2_does_not_require_collab_feature() {
|
||||
let model_info = model_info();
|
||||
@@ -2657,6 +2728,23 @@ fn find_tool<'a>(tools: &'a [ToolSpec], expected_name: &str) -> &'a ToolSpec {
|
||||
.unwrap_or_else(|| panic!("expected tool {expected_name}"))
|
||||
}
|
||||
|
||||
fn assert_namespace_contains_function(
|
||||
tools: &[ToolSpec],
|
||||
expected_namespace: &str,
|
||||
expected_name: &str,
|
||||
) {
|
||||
let namespace_tool = find_tool(tools, expected_namespace);
|
||||
let ToolSpec::Namespace(namespace) = namespace_tool else {
|
||||
panic!("expected namespace tool {expected_namespace}");
|
||||
};
|
||||
assert!(
|
||||
namespace.tools.iter().any(|tool| {
|
||||
matches!(tool, ResponsesApiNamespaceTool::Function(tool) if tool.name == expected_name)
|
||||
}),
|
||||
"expected tool {expected_name} in namespace {expected_namespace}"
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_process_tool_environment_id(
|
||||
tools: &[ToolSpec],
|
||||
expected_name: &str,
|
||||
|
||||
@@ -71,16 +71,10 @@ fn exec_server_params_use_env_policy_overlay_contract() {
|
||||
.expect("current dir")
|
||||
.try_into()
|
||||
.expect("absolute path");
|
||||
let sandbox_policy = codex_protocol::protocol::SandboxPolicy::DangerFullAccess;
|
||||
let file_system_sandbox_policy =
|
||||
codex_protocol::permissions::FileSystemSandboxPolicy::from(&sandbox_policy);
|
||||
codex_protocol::permissions::FileSystemSandboxPolicy::unrestricted();
|
||||
let network_sandbox_policy = codex_protocol::permissions::NetworkSandboxPolicy::Restricted;
|
||||
let permission_profile =
|
||||
codex_protocol::models::PermissionProfile::from_runtime_permissions_with_enforcement(
|
||||
codex_protocol::models::SandboxEnforcement::from_legacy_sandbox_policy(&sandbox_policy),
|
||||
&file_system_sandbox_policy,
|
||||
network_sandbox_policy,
|
||||
);
|
||||
let permission_profile = codex_protocol::models::PermissionProfile::Disabled;
|
||||
let request = ExecRequest {
|
||||
command: vec!["bash".to_string(), "-lc".to_string(), "true".to_string()],
|
||||
cwd: cwd.clone(),
|
||||
|
||||
@@ -15,7 +15,6 @@ use codex_protocol::protocol::ExecApprovalRequestEvent;
|
||||
use codex_protocol::protocol::GranularApprovalConfig;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::request_permissions::PermissionGrantScope;
|
||||
use codex_protocol::request_permissions::RequestPermissionProfile;
|
||||
use codex_protocol::request_permissions::RequestPermissionsResponse;
|
||||
@@ -33,6 +32,7 @@ use core_test_support::skip_if_no_network;
|
||||
use core_test_support::skip_if_sandbox;
|
||||
use core_test_support::test_codex::TestCodex;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::test_codex::turn_permission_fields;
|
||||
use core_test_support::wait_for_event;
|
||||
use pretty_assertions::assert_eq;
|
||||
use regex_lite::Regex;
|
||||
@@ -184,9 +184,11 @@ async fn submit_turn(
|
||||
test: &TestCodex,
|
||||
prompt: &str,
|
||||
approval_policy: AskForApproval,
|
||||
sandbox_policy: SandboxPolicy,
|
||||
permission_profile: CorePermissionProfile,
|
||||
) -> Result<()> {
|
||||
let session_model = test.session_configured.model.clone();
|
||||
let (sandbox_policy, permission_profile) =
|
||||
turn_permission_fields(permission_profile, test.cwd.path());
|
||||
test.codex
|
||||
.submit(Op::UserTurn {
|
||||
environments: None,
|
||||
@@ -199,7 +201,7 @@ async fn submit_turn(
|
||||
approval_policy,
|
||||
approvals_reviewer: Some(ApprovalsReviewer::User),
|
||||
sandbox_policy,
|
||||
permission_profile: None,
|
||||
permission_profile,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: None,
|
||||
@@ -285,16 +287,7 @@ async fn expect_request_permissions_event(
|
||||
}
|
||||
}
|
||||
|
||||
fn workspace_write_excluding_tmp() -> SandboxPolicy {
|
||||
SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![],
|
||||
network_access: false,
|
||||
exclude_tmpdir_env_var: true,
|
||||
exclude_slash_tmp: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn workspace_write_excluding_tmp_profile() -> CorePermissionProfile {
|
||||
fn workspace_write_excluding_tmp() -> CorePermissionProfile {
|
||||
CorePermissionProfile::workspace_write_with(
|
||||
&[],
|
||||
NetworkSandboxPolicy::Restricted,
|
||||
@@ -330,7 +323,7 @@ async fn with_additional_permissions_requires_approval_under_on_request() -> Res
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let approval_policy = AskForApproval::OnRequest;
|
||||
let sandbox_policy = SandboxPolicy::new_read_only_policy();
|
||||
let permission_profile = CorePermissionProfile::read_only();
|
||||
let permission_profile_for_config = CorePermissionProfile::read_only();
|
||||
|
||||
let mut builder = test_codex().with_config(move |config| {
|
||||
@@ -384,7 +377,7 @@ async fn with_additional_permissions_requires_approval_under_on_request() -> Res
|
||||
)
|
||||
.await;
|
||||
|
||||
submit_turn(&test, call_id, approval_policy, sandbox_policy.clone()).await?;
|
||||
submit_turn(&test, call_id, approval_policy, permission_profile.clone()).await?;
|
||||
let approval = expect_exec_approval(&test, command).await;
|
||||
assert_eq!(
|
||||
approval.additional_permissions,
|
||||
@@ -428,7 +421,7 @@ async fn request_permissions_tool_is_auto_denied_when_granular_request_permissio
|
||||
request_permissions: false,
|
||||
mcp_elicitations: true,
|
||||
});
|
||||
let sandbox_policy = SandboxPolicy::new_read_only_policy();
|
||||
let permission_profile = CorePermissionProfile::read_only();
|
||||
let permission_profile_for_config = CorePermissionProfile::read_only();
|
||||
|
||||
let mut builder = test_codex().with_config(move |config| {
|
||||
@@ -476,7 +469,7 @@ async fn request_permissions_tool_is_auto_denied_when_granular_request_permissio
|
||||
&test,
|
||||
"request permissions under granular.request_permissions = false",
|
||||
approval_policy,
|
||||
sandbox_policy,
|
||||
permission_profile,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -514,7 +507,7 @@ async fn relative_additional_permissions_resolve_against_tool_workdir() -> Resul
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let approval_policy = AskForApproval::OnRequest;
|
||||
let sandbox_policy = SandboxPolicy::new_read_only_policy();
|
||||
let permission_profile = CorePermissionProfile::read_only();
|
||||
let permission_profile_for_config = CorePermissionProfile::read_only();
|
||||
|
||||
let mut builder = test_codex().with_config(move |config| {
|
||||
@@ -578,7 +571,7 @@ async fn relative_additional_permissions_resolve_against_tool_workdir() -> Resul
|
||||
)
|
||||
.await;
|
||||
|
||||
submit_turn(&test, call_id, approval_policy, sandbox_policy.clone()).await?;
|
||||
submit_turn(&test, call_id, approval_policy, permission_profile.clone()).await?;
|
||||
|
||||
let approval = expect_exec_approval(&test, command).await;
|
||||
assert_eq!(
|
||||
@@ -618,7 +611,7 @@ async fn read_only_with_additional_permissions_does_not_widen_to_unrequested_cwd
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let approval_policy = AskForApproval::OnRequest;
|
||||
let sandbox_policy = SandboxPolicy::new_read_only_policy();
|
||||
let permission_profile = CorePermissionProfile::read_only();
|
||||
let permission_profile_for_config = CorePermissionProfile::read_only();
|
||||
|
||||
let mut builder = test_codex().with_config(move |config| {
|
||||
@@ -675,7 +668,7 @@ async fn read_only_with_additional_permissions_does_not_widen_to_unrequested_cwd
|
||||
)
|
||||
.await;
|
||||
|
||||
submit_turn(&test, call_id, approval_policy, sandbox_policy.clone()).await?;
|
||||
submit_turn(&test, call_id, approval_policy, permission_profile.clone()).await?;
|
||||
|
||||
let approval = expect_exec_approval(&test, &command).await;
|
||||
assert_eq!(
|
||||
@@ -721,7 +714,7 @@ async fn read_only_with_additional_permissions_does_not_widen_to_unrequested_tmp
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let approval_policy = AskForApproval::OnRequest;
|
||||
let sandbox_policy = SandboxPolicy::new_read_only_policy();
|
||||
let permission_profile = CorePermissionProfile::read_only();
|
||||
let permission_profile_for_config = CorePermissionProfile::read_only();
|
||||
|
||||
let mut builder = test_codex().with_config(move |config| {
|
||||
@@ -779,7 +772,7 @@ async fn read_only_with_additional_permissions_does_not_widen_to_unrequested_tmp
|
||||
)
|
||||
.await;
|
||||
|
||||
submit_turn(&test, call_id, approval_policy, sandbox_policy.clone()).await?;
|
||||
submit_turn(&test, call_id, approval_policy, permission_profile.clone()).await?;
|
||||
|
||||
let approval = expect_exec_approval(&test, &command).await;
|
||||
assert_eq!(
|
||||
@@ -823,8 +816,8 @@ async fn workspace_write_with_additional_permissions_can_write_outside_cwd() ->
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let approval_policy = AskForApproval::OnRequest;
|
||||
let sandbox_policy = workspace_write_excluding_tmp();
|
||||
let permission_profile_for_config = workspace_write_excluding_tmp_profile();
|
||||
let permission_profile = workspace_write_excluding_tmp();
|
||||
let permission_profile_for_config = workspace_write_excluding_tmp();
|
||||
|
||||
let mut builder = test_codex().with_config(move |config| {
|
||||
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
|
||||
@@ -890,7 +883,7 @@ async fn workspace_write_with_additional_permissions_can_write_outside_cwd() ->
|
||||
)
|
||||
.await;
|
||||
|
||||
submit_turn(&test, call_id, approval_policy, sandbox_policy.clone()).await?;
|
||||
submit_turn(&test, call_id, approval_policy, permission_profile.clone()).await?;
|
||||
|
||||
let approval = expect_exec_approval(&test, &command).await;
|
||||
assert_eq!(
|
||||
@@ -930,8 +923,8 @@ async fn with_additional_permissions_denied_approval_blocks_execution() -> Resul
|
||||
skip_if_no_network!(Ok(()));
|
||||
let server = start_mock_server().await;
|
||||
let approval_policy = AskForApproval::OnRequest;
|
||||
let sandbox_policy = workspace_write_excluding_tmp();
|
||||
let permission_profile_for_config = workspace_write_excluding_tmp_profile();
|
||||
let permission_profile = workspace_write_excluding_tmp();
|
||||
let permission_profile_for_config = workspace_write_excluding_tmp();
|
||||
|
||||
let mut builder = test_codex().with_config(move |config| {
|
||||
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
|
||||
@@ -995,7 +988,7 @@ async fn with_additional_permissions_denied_approval_blocks_execution() -> Resul
|
||||
)
|
||||
.await;
|
||||
|
||||
submit_turn(&test, call_id, approval_policy, sandbox_policy.clone()).await?;
|
||||
submit_turn(&test, call_id, approval_policy, permission_profile.clone()).await?;
|
||||
|
||||
let approval = expect_exec_approval(&test, &command).await;
|
||||
assert_eq!(
|
||||
@@ -1038,8 +1031,8 @@ async fn request_permissions_grants_apply_to_later_exec_command_calls() -> Resul
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let approval_policy = AskForApproval::OnRequest;
|
||||
let sandbox_policy = workspace_write_excluding_tmp();
|
||||
let permission_profile_for_config = workspace_write_excluding_tmp_profile();
|
||||
let permission_profile = workspace_write_excluding_tmp();
|
||||
let permission_profile_for_config = workspace_write_excluding_tmp();
|
||||
|
||||
let mut builder = test_codex().with_config(move |config| {
|
||||
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
|
||||
@@ -1110,7 +1103,7 @@ async fn request_permissions_grants_apply_to_later_exec_command_calls() -> Resul
|
||||
&test,
|
||||
"write outside the workspace",
|
||||
approval_policy,
|
||||
sandbox_policy,
|
||||
permission_profile,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -1165,8 +1158,8 @@ async fn request_permissions_preapprove_explicit_exec_permissions_outside_on_req
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let approval_policy = AskForApproval::OnRequest;
|
||||
let sandbox_policy = workspace_write_excluding_tmp();
|
||||
let permission_profile_for_config = workspace_write_excluding_tmp_profile();
|
||||
let permission_profile = workspace_write_excluding_tmp();
|
||||
let permission_profile_for_config = workspace_write_excluding_tmp();
|
||||
|
||||
let mut builder = test_codex().with_config(move |config| {
|
||||
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
|
||||
@@ -1228,7 +1221,7 @@ async fn request_permissions_preapprove_explicit_exec_permissions_outside_on_req
|
||||
&test,
|
||||
"write outside the workspace",
|
||||
approval_policy,
|
||||
sandbox_policy,
|
||||
permission_profile,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -1286,8 +1279,8 @@ async fn request_permissions_grants_apply_to_later_shell_command_calls() -> Resu
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let approval_policy = AskForApproval::OnRequest;
|
||||
let sandbox_policy = workspace_write_excluding_tmp();
|
||||
let permission_profile_for_config = workspace_write_excluding_tmp_profile();
|
||||
let permission_profile = workspace_write_excluding_tmp();
|
||||
let permission_profile_for_config = workspace_write_excluding_tmp();
|
||||
|
||||
let mut builder = test_codex().with_config(move |config| {
|
||||
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
|
||||
@@ -1345,7 +1338,7 @@ async fn request_permissions_grants_apply_to_later_shell_command_calls() -> Resu
|
||||
&test,
|
||||
"write outside the workspace",
|
||||
approval_policy,
|
||||
sandbox_policy,
|
||||
permission_profile,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -1401,8 +1394,8 @@ async fn request_permissions_grants_apply_to_later_shell_command_calls_without_i
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let approval_policy = AskForApproval::OnRequest;
|
||||
let sandbox_policy = workspace_write_excluding_tmp();
|
||||
let permission_profile_for_config = workspace_write_excluding_tmp_profile();
|
||||
let permission_profile = workspace_write_excluding_tmp();
|
||||
let permission_profile_for_config = workspace_write_excluding_tmp();
|
||||
|
||||
let mut builder = test_codex().with_config(move |config| {
|
||||
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
|
||||
@@ -1458,7 +1451,7 @@ async fn request_permissions_grants_apply_to_later_shell_command_calls_without_i
|
||||
&test,
|
||||
"write outside the workspace without inline permission feature",
|
||||
approval_policy,
|
||||
sandbox_policy,
|
||||
permission_profile,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -1516,8 +1509,8 @@ async fn partial_request_permissions_grants_do_not_preapprove_new_permissions()
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let approval_policy = AskForApproval::OnRequest;
|
||||
let sandbox_policy = workspace_write_excluding_tmp();
|
||||
let permission_profile_for_config = workspace_write_excluding_tmp_profile();
|
||||
let permission_profile = workspace_write_excluding_tmp();
|
||||
let permission_profile_for_config = workspace_write_excluding_tmp();
|
||||
|
||||
let mut builder = test_codex().with_config(move |config| {
|
||||
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
|
||||
@@ -1611,7 +1604,7 @@ async fn partial_request_permissions_grants_do_not_preapprove_new_permissions()
|
||||
&test,
|
||||
"write outside the workspace",
|
||||
approval_policy,
|
||||
sandbox_policy,
|
||||
permission_profile,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -1683,8 +1676,8 @@ async fn request_permissions_grants_do_not_carry_across_turns() -> Result<()> {
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let approval_policy = AskForApproval::OnRequest;
|
||||
let sandbox_policy = workspace_write_excluding_tmp();
|
||||
let permission_profile_for_config = workspace_write_excluding_tmp_profile();
|
||||
let permission_profile = workspace_write_excluding_tmp();
|
||||
let permission_profile_for_config = workspace_write_excluding_tmp();
|
||||
|
||||
let mut builder = test_codex().with_config(move |config| {
|
||||
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
|
||||
@@ -1733,7 +1726,7 @@ async fn request_permissions_grants_do_not_carry_across_turns() -> Result<()> {
|
||||
&test,
|
||||
"request permissions for later use",
|
||||
approval_policy,
|
||||
sandbox_policy.clone(),
|
||||
permission_profile.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -1778,7 +1771,7 @@ async fn request_permissions_grants_do_not_carry_across_turns() -> Result<()> {
|
||||
&test,
|
||||
"try to reuse permissions in a later turn",
|
||||
approval_policy,
|
||||
sandbox_policy,
|
||||
permission_profile,
|
||||
)
|
||||
.await?;
|
||||
wait_for_completion(&test).await;
|
||||
@@ -1799,8 +1792,8 @@ async fn request_permissions_session_grants_carry_across_turns() -> Result<()> {
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let approval_policy = AskForApproval::OnRequest;
|
||||
let sandbox_policy = workspace_write_excluding_tmp();
|
||||
let permission_profile_for_config = workspace_write_excluding_tmp_profile();
|
||||
let permission_profile = workspace_write_excluding_tmp();
|
||||
let permission_profile_for_config = workspace_write_excluding_tmp();
|
||||
|
||||
let mut builder = test_codex().with_config(move |config| {
|
||||
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
|
||||
@@ -1854,7 +1847,7 @@ async fn request_permissions_session_grants_carry_across_turns() -> Result<()> {
|
||||
&test,
|
||||
"request session permissions for later use",
|
||||
approval_policy,
|
||||
sandbox_policy.clone(),
|
||||
permission_profile.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -1896,7 +1889,7 @@ async fn request_permissions_session_grants_carry_across_turns() -> Result<()> {
|
||||
&test,
|
||||
"reuse session permissions in a later turn",
|
||||
approval_policy,
|
||||
sandbox_policy,
|
||||
permission_profile,
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::ExecCommandSource;
|
||||
use codex_protocol::protocol::ExecCommandStatus;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use core_test_support::assert_regex_match;
|
||||
use core_test_support::managed_network_requirements_loader;
|
||||
@@ -35,6 +34,7 @@ use core_test_support::skip_if_windows;
|
||||
use core_test_support::test_codex::TestCodex;
|
||||
use core_test_support::test_codex::TestCodexHarness;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::test_codex::turn_permission_fields;
|
||||
use core_test_support::wait_for_event;
|
||||
use core_test_support::wait_for_event_match;
|
||||
use core_test_support::wait_for_event_with_timeout;
|
||||
@@ -185,9 +185,11 @@ async fn wait_for_raw_unified_exec_output(
|
||||
async fn submit_unified_exec_turn(
|
||||
test: &TestCodex,
|
||||
prompt: &str,
|
||||
sandbox_policy: SandboxPolicy,
|
||||
permission_profile: PermissionProfile,
|
||||
) -> Result<()> {
|
||||
let session_model = test.session_configured.model.clone();
|
||||
let (sandbox_policy, permission_profile) =
|
||||
turn_permission_fields(permission_profile, test.config.cwd.as_path());
|
||||
|
||||
test.codex
|
||||
.submit(Op::UserTurn {
|
||||
@@ -201,7 +203,7 @@ async fn submit_unified_exec_turn(
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy,
|
||||
permission_profile: None,
|
||||
permission_profile,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: None,
|
||||
@@ -272,6 +274,8 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> {
|
||||
let codex = test.codex.clone();
|
||||
let cwd = test.cwd_path().to_path_buf();
|
||||
let session_model = test.session_configured.model.clone();
|
||||
let (sandbox_policy, permission_profile) =
|
||||
turn_permission_fields(PermissionProfile::Disabled, &cwd);
|
||||
|
||||
codex
|
||||
.submit(Op::UserTurn {
|
||||
@@ -284,8 +288,8 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> {
|
||||
cwd,
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
permission_profile: None,
|
||||
sandbox_policy,
|
||||
permission_profile,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: None,
|
||||
@@ -403,7 +407,7 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> {
|
||||
];
|
||||
mount_sse_sequence(&server, responses).await;
|
||||
|
||||
submit_unified_exec_turn(&test, "emit begin event", SandboxPolicy::DangerFullAccess).await?;
|
||||
submit_unified_exec_turn(&test, "emit begin event", PermissionProfile::Disabled).await?;
|
||||
|
||||
let begin_event = wait_for_event_match(&test.codex, |msg| match msg {
|
||||
EventMsg::ExecCommandBegin(event) if event.call_id == call_id => Some(event.clone()),
|
||||
@@ -467,7 +471,7 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> {
|
||||
submit_unified_exec_turn(
|
||||
&test,
|
||||
"run relative workdir test",
|
||||
SandboxPolicy::DangerFullAccess,
|
||||
PermissionProfile::Disabled,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -532,7 +536,7 @@ async fn unified_exec_respects_workdir_override() -> Result<()> {
|
||||
];
|
||||
let request_log = mount_sse_sequence(&server, responses).await;
|
||||
|
||||
submit_unified_exec_turn(&test, "run workdir test", SandboxPolicy::DangerFullAccess).await?;
|
||||
submit_unified_exec_turn(&test, "run workdir test", PermissionProfile::Disabled).await?;
|
||||
|
||||
let begin_event = wait_for_event_match(&test.codex, |msg| match msg {
|
||||
EventMsg::ExecCommandBegin(event) if event.call_id == call_id => Some(event.clone()),
|
||||
@@ -609,7 +613,7 @@ async fn unified_exec_emits_exec_command_end_event() -> Result<()> {
|
||||
];
|
||||
mount_sse_sequence(&server, responses).await;
|
||||
|
||||
submit_unified_exec_turn(&test, "emit end event", SandboxPolicy::DangerFullAccess).await?;
|
||||
submit_unified_exec_turn(&test, "emit end event", PermissionProfile::Disabled).await?;
|
||||
|
||||
let end_event = wait_for_event_match(&test.codex, |msg| match msg {
|
||||
EventMsg::ExecCommandEnd(ev) if ev.call_id == call_id => Some(ev.clone()),
|
||||
@@ -667,7 +671,7 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> {
|
||||
];
|
||||
mount_sse_sequence(&server, responses).await;
|
||||
|
||||
submit_unified_exec_turn(&test, "emit delta", SandboxPolicy::DangerFullAccess).await?;
|
||||
submit_unified_exec_turn(&test, "emit delta", PermissionProfile::Disabled).await?;
|
||||
|
||||
let event = wait_for_event_match(&test.codex, |msg| match msg {
|
||||
EventMsg::ExecCommandEnd(ev) if ev.call_id == call_id => Some(ev.clone()),
|
||||
@@ -729,7 +733,7 @@ async fn unified_exec_full_lifecycle_with_background_end_event() -> Result<()> {
|
||||
submit_unified_exec_turn(
|
||||
&test,
|
||||
"exercise full unified exec lifecycle",
|
||||
SandboxPolicy::DangerFullAccess,
|
||||
PermissionProfile::Disabled,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -872,7 +876,7 @@ async fn unified_exec_short_lived_network_denial_emits_failed_end_event() -> Res
|
||||
#[allow(clippy::expect_used)]
|
||||
async fn unified_exec_network_denial_test(
|
||||
server: &wiremock::MockServer,
|
||||
) -> Result<(TestCodex, SandboxPolicy)> {
|
||||
) -> Result<(TestCodex, PermissionProfile)> {
|
||||
use codex_config::Constrained;
|
||||
use std::sync::Arc;
|
||||
use tempfile::TempDir;
|
||||
@@ -897,10 +901,7 @@ allow_local_binding = true
|
||||
/*exclude_tmpdir_env_var*/ false,
|
||||
/*exclude_slash_tmp*/ false,
|
||||
);
|
||||
let sandbox_policy = permission_profile_for_config
|
||||
.clone()
|
||||
.to_legacy_sandbox_policy(home.path())
|
||||
.expect("workspace-write profile should project to legacy policy");
|
||||
let permission_profile = permission_profile_for_config.clone();
|
||||
let mut builder = test_codex()
|
||||
.with_home(home)
|
||||
.with_cloud_requirements(managed_network_requirements_loader())
|
||||
@@ -922,7 +923,7 @@ allow_local_binding = true
|
||||
"expected managed network proxy config to be present"
|
||||
);
|
||||
|
||||
Ok((test, sandbox_policy))
|
||||
Ok((test, permission_profile))
|
||||
}
|
||||
|
||||
async fn mount_unified_exec_network_denial_responses(
|
||||
@@ -1040,7 +1041,7 @@ async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()>
|
||||
];
|
||||
mount_sse_sequence(&server, responses).await;
|
||||
|
||||
submit_unified_exec_turn(&test, "stdin delta", SandboxPolicy::DangerFullAccess).await?;
|
||||
submit_unified_exec_turn(&test, "stdin delta", PermissionProfile::Disabled).await?;
|
||||
|
||||
let mut terminal_interaction = None;
|
||||
|
||||
@@ -1160,7 +1161,7 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<(
|
||||
submit_unified_exec_turn(
|
||||
&test,
|
||||
"delayed terminal interaction output",
|
||||
SandboxPolicy::DangerFullAccess,
|
||||
PermissionProfile::Disabled,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -1305,7 +1306,7 @@ async fn unified_exec_emits_one_begin_and_one_end_event() -> Result<()> {
|
||||
submit_unified_exec_turn(
|
||||
&test,
|
||||
"check poll event behavior",
|
||||
SandboxPolicy::DangerFullAccess,
|
||||
PermissionProfile::Disabled,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -1395,7 +1396,7 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> {
|
||||
];
|
||||
let request_log = mount_sse_sequence(&server, responses).await;
|
||||
|
||||
submit_unified_exec_turn(&test, "run metadata test", SandboxPolicy::DangerFullAccess).await?;
|
||||
submit_unified_exec_turn(&test, "run metadata test", PermissionProfile::Disabled).await?;
|
||||
|
||||
wait_for_event(&test.codex, |event| {
|
||||
matches!(event, EventMsg::TurnComplete(_))
|
||||
@@ -1494,7 +1495,7 @@ async fn exec_command_clamps_model_requested_max_output_tokens_to_policy() -> Re
|
||||
submit_unified_exec_turn(
|
||||
&test,
|
||||
"run clamped max output test",
|
||||
SandboxPolicy::DangerFullAccess,
|
||||
PermissionProfile::Disabled,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -1577,7 +1578,7 @@ async fn write_stdin_clamps_model_requested_max_output_tokens_to_policy() -> Res
|
||||
submit_unified_exec_turn(
|
||||
&test,
|
||||
"run clamped write_stdin output test",
|
||||
SandboxPolicy::DangerFullAccess,
|
||||
PermissionProfile::Disabled,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -1642,7 +1643,7 @@ async fn unified_exec_defaults_to_pipe() -> Result<()> {
|
||||
submit_unified_exec_turn(
|
||||
&test,
|
||||
"check default pipe mode",
|
||||
SandboxPolicy::DangerFullAccess,
|
||||
PermissionProfile::Disabled,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -1709,7 +1710,7 @@ async fn unified_exec_can_enable_tty() -> Result<()> {
|
||||
];
|
||||
let request_log = mount_sse_sequence(&server, responses).await;
|
||||
|
||||
submit_unified_exec_turn(&test, "check tty enabled", SandboxPolicy::DangerFullAccess).await?;
|
||||
submit_unified_exec_turn(&test, "check tty enabled", PermissionProfile::Disabled).await?;
|
||||
|
||||
wait_for_event(&test.codex, |event| {
|
||||
matches!(event, EventMsg::TurnComplete(_))
|
||||
@@ -1776,7 +1777,7 @@ async fn unified_exec_respects_early_exit_notifications() -> Result<()> {
|
||||
submit_unified_exec_turn(
|
||||
&test,
|
||||
"watch early exit timing",
|
||||
SandboxPolicy::DangerFullAccess,
|
||||
PermissionProfile::Disabled,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -1895,7 +1896,7 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> {
|
||||
submit_unified_exec_turn(
|
||||
&test,
|
||||
"test write_stdin exit behavior",
|
||||
SandboxPolicy::DangerFullAccess,
|
||||
PermissionProfile::Disabled,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -2048,7 +2049,7 @@ async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<()
|
||||
];
|
||||
mount_sse_sequence(&server, responses).await;
|
||||
|
||||
submit_unified_exec_turn(&test, "end on exit", SandboxPolicy::DangerFullAccess).await?;
|
||||
submit_unified_exec_turn(&test, "end on exit", PermissionProfile::Disabled).await?;
|
||||
|
||||
// We expect the ExecCommandEnd event to match the initial exec_command call_id.
|
||||
let end_event = wait_for_event_match(&test.codex, |msg| match msg {
|
||||
@@ -2114,6 +2115,9 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()>
|
||||
mount_sse_sequence(&server, responses).await;
|
||||
|
||||
let session_model = session_configured.model.clone();
|
||||
let turn_cwd = cwd.path().to_path_buf();
|
||||
let (sandbox_policy, permission_profile) =
|
||||
turn_permission_fields(PermissionProfile::Disabled, &turn_cwd);
|
||||
|
||||
codex
|
||||
.submit(Op::UserTurn {
|
||||
@@ -2123,11 +2127,11 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()>
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
cwd: turn_cwd,
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
permission_profile: None,
|
||||
sandbox_policy,
|
||||
permission_profile,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: None,
|
||||
@@ -2209,6 +2213,9 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> {
|
||||
mount_sse_sequence(&server, responses).await;
|
||||
|
||||
let session_model = session_configured.model.clone();
|
||||
let turn_cwd = cwd.path().to_path_buf();
|
||||
let (sandbox_policy, permission_profile) =
|
||||
turn_permission_fields(PermissionProfile::Disabled, &turn_cwd);
|
||||
|
||||
codex
|
||||
.submit(Op::UserTurn {
|
||||
@@ -2218,11 +2225,11 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> {
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
cwd: turn_cwd,
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
permission_profile: None,
|
||||
sandbox_policy,
|
||||
permission_profile,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: None,
|
||||
@@ -2314,7 +2321,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> {
|
||||
];
|
||||
let request_log = mount_sse_sequence(&server, responses).await;
|
||||
|
||||
submit_unified_exec_turn(&test, "run unified exec", SandboxPolicy::DangerFullAccess).await?;
|
||||
submit_unified_exec_turn(&test, "run unified exec", PermissionProfile::Disabled).await?;
|
||||
|
||||
wait_for_event(&test.codex, |event| {
|
||||
matches!(event, EventMsg::TurnComplete(_))
|
||||
@@ -2432,12 +2439,7 @@ PY
|
||||
];
|
||||
let request_log = mount_sse_sequence(&server, responses).await;
|
||||
|
||||
submit_unified_exec_turn(
|
||||
&test,
|
||||
"exercise lag handling",
|
||||
SandboxPolicy::DangerFullAccess,
|
||||
)
|
||||
.await?;
|
||||
submit_unified_exec_turn(&test, "exercise lag handling", PermissionProfile::Disabled).await?;
|
||||
// This is a worst case scenario for the truncate logic, and CI can spend a
|
||||
// while draining the lagged tail before the follow-up tool call completes.
|
||||
wait_for_event_with_timeout(
|
||||
@@ -2532,7 +2534,7 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> {
|
||||
];
|
||||
let request_log = mount_sse_sequence(&server, responses).await;
|
||||
|
||||
submit_unified_exec_turn(&test, "check timeout", SandboxPolicy::DangerFullAccess).await?;
|
||||
submit_unified_exec_turn(&test, "check timeout", PermissionProfile::Disabled).await?;
|
||||
|
||||
loop {
|
||||
let event = test.codex.next_event().await.expect("event");
|
||||
@@ -2608,12 +2610,7 @@ PY
|
||||
];
|
||||
let request_log = mount_sse_sequence(&server, responses).await;
|
||||
|
||||
submit_unified_exec_turn(
|
||||
&test,
|
||||
"summarize large output",
|
||||
SandboxPolicy::DangerFullAccess,
|
||||
)
|
||||
.await?;
|
||||
submit_unified_exec_turn(&test, "summarize large output", PermissionProfile::Disabled).await?;
|
||||
|
||||
wait_for_event(&test.codex, |event| {
|
||||
matches!(event, EventMsg::TurnComplete(_))
|
||||
@@ -2683,6 +2680,9 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> {
|
||||
let request_log = mount_sse_sequence(&server, responses).await;
|
||||
|
||||
let session_model = session_configured.model.clone();
|
||||
let turn_cwd = cwd.path().to_path_buf();
|
||||
let (sandbox_policy, permission_profile) =
|
||||
turn_permission_fields(PermissionProfile::read_only(), &turn_cwd);
|
||||
|
||||
codex
|
||||
.submit(Op::UserTurn {
|
||||
@@ -2692,12 +2692,12 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> {
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
cwd: turn_cwd,
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
// Important!
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
permission_profile: None,
|
||||
sandbox_policy,
|
||||
permission_profile,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: None,
|
||||
@@ -2799,7 +2799,9 @@ async fn unified_exec_enforces_glob_deny_read_policy() -> Result<()> {
|
||||
let request_log = mount_sse_sequence(&server, responses).await;
|
||||
|
||||
let session_model = session_configured.model.clone();
|
||||
let read_only_policy = SandboxPolicy::new_read_only_policy();
|
||||
let turn_cwd = cwd.path().to_path_buf();
|
||||
let (sandbox_policy, permission_profile) =
|
||||
turn_permission_fields(PermissionProfile::read_only(), &turn_cwd);
|
||||
codex
|
||||
.submit(Op::UserTurn {
|
||||
environments: None,
|
||||
@@ -2808,11 +2810,11 @@ async fn unified_exec_enforces_glob_deny_read_policy() -> Result<()> {
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
cwd: turn_cwd,
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: read_only_policy,
|
||||
permission_profile: None,
|
||||
sandbox_policy,
|
||||
permission_profile,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: None,
|
||||
@@ -2929,6 +2931,9 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> {
|
||||
let request_log = mount_sse_sequence(&server, responses).await;
|
||||
|
||||
let session_model = session_configured.model.clone();
|
||||
let turn_cwd = cwd.path().to_path_buf();
|
||||
let (sandbox_policy, permission_profile) =
|
||||
turn_permission_fields(PermissionProfile::read_only(), &turn_cwd);
|
||||
|
||||
codex
|
||||
.submit(Op::UserTurn {
|
||||
@@ -2938,11 +2943,11 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> {
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
cwd: turn_cwd,
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
permission_profile: None,
|
||||
sandbox_policy,
|
||||
permission_profile,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: None,
|
||||
@@ -3025,12 +3030,7 @@ async fn unified_exec_runs_on_all_platforms() -> Result<()> {
|
||||
];
|
||||
let request_log = mount_sse_sequence(&server, responses).await;
|
||||
|
||||
submit_unified_exec_turn(
|
||||
&test,
|
||||
"summarize large output",
|
||||
SandboxPolicy::DangerFullAccess,
|
||||
)
|
||||
.await?;
|
||||
submit_unified_exec_turn(&test, "summarize large output", PermissionProfile::Disabled).await?;
|
||||
|
||||
wait_for_event(&test.codex, |event| {
|
||||
matches!(event, EventMsg::TurnComplete(_))
|
||||
@@ -3148,7 +3148,7 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> {
|
||||
let response_mock =
|
||||
mount_sse_sequence(&server, vec![first_response, completion_response]).await;
|
||||
|
||||
submit_unified_exec_turn(&test, "fill session cache", SandboxPolicy::DangerFullAccess).await?;
|
||||
submit_unified_exec_turn(&test, "fill session cache", PermissionProfile::Disabled).await?;
|
||||
|
||||
wait_for_event(&test.codex, |event| {
|
||||
matches!(event, EventMsg::TurnComplete(_))
|
||||
|
||||
@@ -30,6 +30,10 @@ use tokio::io::BufWriter;
|
||||
|
||||
pub(crate) const CHANNEL_CAPACITY: usize = 128;
|
||||
const STDIO_TERMINATION_GRACE_PERIOD: Duration = Duration::from_secs(2);
|
||||
#[cfg(test)]
|
||||
pub(crate) const WEBSOCKET_KEEPALIVE_INTERVAL: Duration = Duration::from_millis(25);
|
||||
#[cfg(not(test))]
|
||||
pub(crate) const WEBSOCKET_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(30);
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum JsonRpcConnectionEvent {
|
||||
@@ -320,18 +324,32 @@ impl JsonRpcConnection {
|
||||
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
let (websocket_writer, websocket_reader) = stream.split();
|
||||
Self::from_websocket_parts(websocket_writer, websocket_reader, connection_label)
|
||||
Self::from_websocket_parts(
|
||||
websocket_writer,
|
||||
websocket_reader,
|
||||
connection_label,
|
||||
Some(WEBSOCKET_KEEPALIVE_INTERVAL),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn from_axum_websocket(stream: AxumWebSocket, connection_label: String) -> Self {
|
||||
let (websocket_writer, websocket_reader) = stream.split();
|
||||
Self::from_websocket_parts(websocket_writer, websocket_reader, connection_label)
|
||||
Self::from_websocket_parts(
|
||||
websocket_writer,
|
||||
websocket_reader,
|
||||
connection_label,
|
||||
// Axum only wraps inbound exec-server websocket accepts. Outbound websocket clients
|
||||
// own keepalive pings so one side does not accidentally create redundant traffic.
|
||||
/*keepalive_interval*/
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
fn from_websocket_parts<W, R, M, E>(
|
||||
mut websocket_writer: W,
|
||||
mut websocket_reader: R,
|
||||
connection_label: String,
|
||||
keepalive_interval: Option<Duration>,
|
||||
) -> Self
|
||||
where
|
||||
W: Sink<M, Error = E> + Unpin + Send + 'static,
|
||||
@@ -404,30 +422,54 @@ impl JsonRpcConnection {
|
||||
});
|
||||
|
||||
let writer_task = tokio::spawn(async move {
|
||||
while let Some(message) = outgoing_rx.recv().await {
|
||||
match serialize_jsonrpc_message(&message) {
|
||||
Ok(encoded) => {
|
||||
if let Err(err) = websocket_writer.send(M::from_text(encoded)).await {
|
||||
send_disconnected(
|
||||
&incoming_tx,
|
||||
&disconnected_tx,
|
||||
Some(format!(
|
||||
"failed to write websocket JSON-RPC message to {connection_label}: {err}"
|
||||
)),
|
||||
if let Some(keepalive_interval) = keepalive_interval {
|
||||
let mut keepalive = tokio::time::interval_at(
|
||||
tokio::time::Instant::now() + keepalive_interval,
|
||||
keepalive_interval,
|
||||
);
|
||||
keepalive.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
loop {
|
||||
tokio::select! {
|
||||
maybe_message = outgoing_rx.recv() => {
|
||||
let Some(message) = maybe_message else {
|
||||
break;
|
||||
};
|
||||
if let Err(reason) = send_websocket_jsonrpc_message(
|
||||
&mut websocket_writer,
|
||||
&connection_label,
|
||||
&message,
|
||||
)
|
||||
.await;
|
||||
break;
|
||||
.await
|
||||
{
|
||||
send_disconnected(&incoming_tx, &disconnected_tx, Some(reason)).await;
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ = keepalive.tick() => {
|
||||
if let Err(err) = websocket_writer.send(M::ping()).await {
|
||||
send_disconnected(
|
||||
&incoming_tx,
|
||||
&disconnected_tx,
|
||||
Some(format!(
|
||||
"failed to write websocket ping to {connection_label}: {err}"
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
send_disconnected(
|
||||
&incoming_tx,
|
||||
&disconnected_tx,
|
||||
Some(format!(
|
||||
"failed to serialize JSON-RPC message for {connection_label}: {err}"
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
} else {
|
||||
while let Some(message) = outgoing_rx.recv().await {
|
||||
if let Err(reason) = send_websocket_jsonrpc_message(
|
||||
&mut websocket_writer,
|
||||
&connection_label,
|
||||
&message,
|
||||
)
|
||||
.await
|
||||
{
|
||||
send_disconnected(&incoming_tx, &disconnected_tx, Some(reason)).await;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -458,6 +500,7 @@ enum JsonRpcWebSocketFrame {
|
||||
trait JsonRpcWebSocketMessage: Send + 'static {
|
||||
fn parse_jsonrpc_frame(self) -> Result<JsonRpcWebSocketFrame, serde_json::Error>;
|
||||
fn from_text(text: String) -> Self;
|
||||
fn ping() -> Self;
|
||||
}
|
||||
|
||||
impl JsonRpcWebSocketMessage for Message {
|
||||
@@ -479,6 +522,10 @@ impl JsonRpcWebSocketMessage for Message {
|
||||
fn from_text(text: String) -> Self {
|
||||
Self::Text(text.into())
|
||||
}
|
||||
|
||||
fn ping() -> Self {
|
||||
Self::Ping(Vec::new().into())
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonRpcWebSocketMessage for AxumWebSocketMessage {
|
||||
@@ -500,6 +547,10 @@ impl JsonRpcWebSocketMessage for AxumWebSocketMessage {
|
||||
fn from_text(text: String) -> Self {
|
||||
Self::Text(text.into())
|
||||
}
|
||||
|
||||
fn ping() -> Self {
|
||||
Self::Ping(Vec::new().into())
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_disconnected(
|
||||
@@ -538,6 +589,100 @@ where
|
||||
writer.flush().await
|
||||
}
|
||||
|
||||
async fn send_websocket_jsonrpc_message<W, M, E>(
|
||||
websocket_writer: &mut W,
|
||||
connection_label: &str,
|
||||
message: &JSONRPCMessage,
|
||||
) -> Result<(), String>
|
||||
where
|
||||
W: Sink<M, Error = E> + Unpin,
|
||||
M: JsonRpcWebSocketMessage,
|
||||
E: std::fmt::Display,
|
||||
{
|
||||
match serialize_jsonrpc_message(message) {
|
||||
Ok(encoded) => websocket_writer
|
||||
.send(M::from_text(encoded))
|
||||
.await
|
||||
.map_err(|err| {
|
||||
format!("failed to write websocket JSON-RPC message to {connection_label}: {err}")
|
||||
}),
|
||||
Err(err) => Err(format!(
|
||||
"failed to serialize JSON-RPC message for {connection_label}: {err}"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn serialize_jsonrpc_message(message: &JSONRPCMessage) -> Result<String, serde_json::Error> {
|
||||
serde_json::to_string(message)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::pin::Pin;
|
||||
|
||||
use futures::channel::mpsc as futures_mpsc;
|
||||
use futures::stream;
|
||||
use futures::task::Context;
|
||||
use futures::task::Poll;
|
||||
use tokio::time::timeout;
|
||||
|
||||
use super::*;
|
||||
|
||||
struct TestWebSocketSink {
|
||||
message_tx: futures_mpsc::UnboundedSender<Message>,
|
||||
}
|
||||
|
||||
impl Sink<Message> for TestWebSocketSink {
|
||||
type Error = std::convert::Infallible;
|
||||
|
||||
fn poll_ready(
|
||||
self: Pin<&mut Self>,
|
||||
_cx: &mut Context<'_>,
|
||||
) -> Poll<Result<(), Self::Error>> {
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
|
||||
fn start_send(self: Pin<&mut Self>, item: Message) -> Result<(), Self::Error> {
|
||||
self.get_mut()
|
||||
.message_tx
|
||||
.unbounded_send(item)
|
||||
.expect("test websocket receiver should stay open");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn poll_flush(
|
||||
self: Pin<&mut Self>,
|
||||
_cx: &mut Context<'_>,
|
||||
) -> Poll<Result<(), Self::Error>> {
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
|
||||
fn poll_close(
|
||||
self: Pin<&mut Self>,
|
||||
_cx: &mut Context<'_>,
|
||||
) -> Poll<Result<(), Self::Error>> {
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_connection_sends_keepalive_ping() {
|
||||
let (message_tx, mut message_rx) = futures_mpsc::unbounded::<Message>();
|
||||
let websocket_writer = TestWebSocketSink { message_tx };
|
||||
let websocket_reader = stream::pending::<Result<Message, std::convert::Infallible>>();
|
||||
let connection = JsonRpcConnection::from_websocket_parts(
|
||||
websocket_writer,
|
||||
websocket_reader,
|
||||
"test".into(),
|
||||
Some(WEBSOCKET_KEEPALIVE_INTERVAL),
|
||||
);
|
||||
|
||||
let message = timeout(Duration::from_secs(1), message_rx.next())
|
||||
.await
|
||||
.expect("keepalive ping should arrive before timeout")
|
||||
.expect("keepalive ping should be sent");
|
||||
assert!(matches!(message, Message::Ping(_)));
|
||||
|
||||
drop(connection);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ use crate::connection::CHANNEL_CAPACITY;
|
||||
use crate::connection::JsonRpcConnection;
|
||||
use crate::connection::JsonRpcConnectionEvent;
|
||||
use crate::connection::JsonRpcTransport;
|
||||
use crate::connection::WEBSOCKET_KEEPALIVE_INTERVAL;
|
||||
use crate::relay_proto::RelayData;
|
||||
use crate::relay_proto::RelayMessageFrame;
|
||||
use crate::relay_proto::RelayResume;
|
||||
@@ -262,24 +263,42 @@ where
|
||||
return;
|
||||
}
|
||||
|
||||
let mut keepalive = tokio::time::interval_at(
|
||||
tokio::time::Instant::now() + WEBSOCKET_KEEPALIVE_INTERVAL,
|
||||
WEBSOCKET_KEEPALIVE_INTERVAL,
|
||||
);
|
||||
keepalive.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
let mut next_seq = 0u32;
|
||||
while let Some(message) = outgoing_rx.recv().await {
|
||||
let payload = match jsonrpc_payload(&message) {
|
||||
Ok(payload) => payload,
|
||||
Err(err) => {
|
||||
warn!("failed to serialize JSON-RPC payload for relay transport: {err}");
|
||||
break;
|
||||
loop {
|
||||
tokio::select! {
|
||||
maybe_message = outgoing_rx.recv() => {
|
||||
let Some(message) = maybe_message else {
|
||||
break;
|
||||
};
|
||||
let payload = match jsonrpc_payload(&message) {
|
||||
Ok(payload) => payload,
|
||||
Err(err) => {
|
||||
warn!("failed to serialize JSON-RPC payload for relay transport: {err}");
|
||||
break;
|
||||
}
|
||||
};
|
||||
let frame = RelayMessageFrame::data(stream_id.clone(), next_seq, payload);
|
||||
next_seq = next_seq.wrapping_add(1);
|
||||
if websocket_writer
|
||||
.send(Message::Binary(encode_relay_message_frame(&frame).into()))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
let _ = disconnected_tx.send(true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ = keepalive.tick() => {
|
||||
if websocket_writer.send(Message::Ping(Vec::new().into())).await.is_err() {
|
||||
let _ = disconnected_tx.send(true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
let frame = RelayMessageFrame::data(stream_id.clone(), next_seq, payload);
|
||||
next_seq = next_seq.wrapping_add(1);
|
||||
if websocket_writer
|
||||
.send(Message::Binary(encode_relay_message_frame(&frame).into()))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
let _ = disconnected_tx.send(true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -303,13 +322,30 @@ pub(crate) async fn run_multiplexed_executor<S>(
|
||||
let (physical_outgoing_tx, mut physical_outgoing_rx) =
|
||||
mpsc::channel::<Vec<u8>>(CHANNEL_CAPACITY);
|
||||
let writer_task = tokio::spawn(async move {
|
||||
while let Some(encoded) = physical_outgoing_rx.recv().await {
|
||||
if websocket_writer
|
||||
.send(Message::Binary(encoded.into()))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
let mut keepalive = tokio::time::interval_at(
|
||||
tokio::time::Instant::now() + WEBSOCKET_KEEPALIVE_INTERVAL,
|
||||
WEBSOCKET_KEEPALIVE_INTERVAL,
|
||||
);
|
||||
keepalive.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
loop {
|
||||
tokio::select! {
|
||||
maybe_encoded = physical_outgoing_rx.recv() => {
|
||||
let Some(encoded) = maybe_encoded else {
|
||||
break;
|
||||
};
|
||||
if websocket_writer
|
||||
.send(Message::Binary(encoded.into()))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ = keepalive.tick() => {
|
||||
if websocket_writer.send(Message::Ping(Vec::new().into())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -453,3 +489,80 @@ fn spawn_virtual_stream(
|
||||
disconnected_tx,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::time::timeout;
|
||||
use tokio_tungstenite::accept_async;
|
||||
use tokio_tungstenite::connect_async;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn test_runtime_paths() -> anyhow::Result<crate::ExecServerRuntimePaths> {
|
||||
crate::ExecServerRuntimePaths::new(
|
||||
std::env::current_exe()?,
|
||||
/*codex_linux_sandbox_exe*/ None,
|
||||
)
|
||||
.map_err(anyhow::Error::from)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multiplexed_executor_sends_keepalive_ping() -> anyhow::Result<()> {
|
||||
let (client_websocket, mut server_websocket) = websocket_pair().await?;
|
||||
let executor_task = tokio::spawn(run_multiplexed_executor(
|
||||
client_websocket,
|
||||
ConnectionProcessor::new(test_runtime_paths()?),
|
||||
));
|
||||
|
||||
read_keepalive_ping(&mut server_websocket).await?;
|
||||
|
||||
executor_task.abort();
|
||||
let _ = executor_task.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn harness_connection_sends_keepalive_ping() -> anyhow::Result<()> {
|
||||
let (client_websocket, mut server_websocket) = websocket_pair().await?;
|
||||
let connection = harness_connection_from_websocket(client_websocket, "test".to_string());
|
||||
|
||||
read_keepalive_ping(&mut server_websocket).await?;
|
||||
|
||||
drop(connection);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn websocket_pair() -> anyhow::Result<(
|
||||
WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>,
|
||||
WebSocketStream<tokio::net::TcpStream>,
|
||||
)> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||
let websocket_url = format!("ws://{}", listener.local_addr()?);
|
||||
let server_task = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await?;
|
||||
accept_async(stream).await.map_err(anyhow::Error::from)
|
||||
});
|
||||
let (client_websocket, _) = connect_async(websocket_url).await?;
|
||||
let server_websocket = server_task.await??;
|
||||
Ok((client_websocket, server_websocket))
|
||||
}
|
||||
|
||||
async fn read_keepalive_ping(
|
||||
websocket: &mut WebSocketStream<tokio::net::TcpStream>,
|
||||
) -> anyhow::Result<()> {
|
||||
loop {
|
||||
let Some(message) = timeout(Duration::from_secs(1), websocket.next()).await? else {
|
||||
anyhow::bail!("websocket closed before keepalive ping");
|
||||
};
|
||||
match message? {
|
||||
Message::Ping(_) => return Ok(()),
|
||||
Message::Binary(_) | Message::Text(_) | Message::Pong(_) | Message::Frame(_) => {}
|
||||
Message::Close(_) => anyhow::bail!("websocket closed before keepalive ping"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,9 @@ pub struct MultiAgentV2ConfigToml {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub subagent_usage_hint_text: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(length(min = 1, max = 64), regex(pattern = r"^[a-zA-Z0-9_-]+$"))]
|
||||
pub tool_namespace: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub hide_spawn_agent_metadata: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub non_code_mode_only: Option<bool>,
|
||||
|
||||
@@ -528,6 +528,7 @@ usage_hint_enabled = false
|
||||
usage_hint_text = "Custom delegation guidance."
|
||||
root_agent_usage_hint_text = "Root guidance."
|
||||
subagent_usage_hint_text = "Subagent guidance."
|
||||
tool_namespace = "agents"
|
||||
hide_spawn_agent_metadata = true
|
||||
non_code_mode_only = true
|
||||
"#,
|
||||
@@ -550,6 +551,7 @@ non_code_mode_only = true
|
||||
usage_hint_text: Some("Custom delegation guidance.".to_string()),
|
||||
root_agent_usage_hint_text: Some("Root guidance.".to_string()),
|
||||
subagent_usage_hint_text: Some("Subagent guidance.".to_string()),
|
||||
tool_namespace: Some("agents".to_string()),
|
||||
hide_spawn_agent_metadata: Some(true),
|
||||
non_code_mode_only: Some(true),
|
||||
}))
|
||||
@@ -588,6 +590,7 @@ usage_hint_enabled = false
|
||||
usage_hint_text: None,
|
||||
root_agent_usage_hint_text: None,
|
||||
subagent_usage_hint_text: None,
|
||||
tool_namespace: None,
|
||||
hide_spawn_agent_metadata: None,
|
||||
non_code_mode_only: None,
|
||||
}))
|
||||
|
||||
@@ -13,7 +13,7 @@ use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
|
||||
pub use prompts::build_memory_tool_developer_instructions;
|
||||
|
||||
const MEMORY_TOOL_DEVELOPER_INSTRUCTIONS_SUMMARY_TOKEN_LIMIT: usize = 5_000;
|
||||
const MEMORY_TOOL_DEVELOPER_INSTRUCTIONS_SUMMARY_TOKEN_LIMIT: usize = 2_500;
|
||||
|
||||
pub fn memory_root(codex_home: &AbsolutePathBuf) -> AbsolutePathBuf {
|
||||
codex_home.join("memories")
|
||||
|
||||
@@ -20,8 +20,8 @@ CONTEXT: MEMORY FOLDER STRUCTURE
|
||||
Folder structure (under {{ memory_root }}/):
|
||||
|
||||
- memory_summary.md
|
||||
- Always loaded into the system prompt. Must remain informative and highly navigational,
|
||||
but still discriminative enough to guide retrieval.
|
||||
- Always loaded into the system prompt. First line must be exactly `v1`.
|
||||
Must stay dense, highly navigational, and discriminative enough to guide retrieval.
|
||||
- MEMORY.md
|
||||
- Handbook entries. Used to grep for keywords; aggregated insights from rollouts;
|
||||
pointers to rollout summaries if certain past rollouts are very relevant.
|
||||
@@ -133,7 +133,8 @@ Under `{{ memory_root }}/`:
|
||||
- merged memories; produce a lightly clustered version if applicable
|
||||
- `rollout_summaries/*.md`
|
||||
- `memory_summary.md`
|
||||
- read the existing summary so updates stay consistent
|
||||
- read the existing summary so updates stay consistent only if its first line is exactly `v1`;
|
||||
otherwise treat the summary as schema-incompatible and regenerate the whole file from scratch
|
||||
- `skills/*`
|
||||
- read existing skills so updates are incremental and non-duplicative
|
||||
{{ memory_extensions_primary_inputs }}
|
||||
@@ -143,6 +144,8 @@ Mode selection:
|
||||
and `skills/`).
|
||||
- INCREMENTAL UPDATE: existing artifacts already exist and `raw_memories.md`
|
||||
mostly contains new additions.
|
||||
- Summary schema reset: if `memory_summary.md` is missing, empty, or does not start with exactly
|
||||
`v1`, regenerate only `memory_summary.md` from scratch after `MEMORY.md` is current.
|
||||
|
||||
Memory workspace diff:
|
||||
|
||||
@@ -183,6 +186,8 @@ Rules:
|
||||
|
||||
- If there is no meaningful signal to add beyond what already exists, keep outputs minimal.
|
||||
- You should always make sure `MEMORY.md` and `memory_summary.md` exist and are up to date.
|
||||
- `memory_summary.md` must start with the exact line `v1`; if it does not, rewrite the entire
|
||||
file rather than patching the previous summary in place.
|
||||
- Follow the format and schema of the artifacts below.
|
||||
- Do not target fixed counts (memory blocks, task groups, topics, or bullets). Let the
|
||||
signal determine the granularity and depth.
|
||||
@@ -441,14 +446,40 @@ task group/task` from the full raw inventory so you can have a better overview.
|
||||
- Prefer placing reusable user preferences in `## User preferences` and the rest of the durable
|
||||
know-how in `## Reusable knowledge` and `## Failures and how to do differently`.
|
||||
- Use `memory_summary.md` as the cross-task summary layer, not the place for project-specific
|
||||
runbooks. It should stay compact in narrative/profile sections, but its `## User preferences`
|
||||
section is the main actionable payload and may be much longer when that helps future agents
|
||||
avoid repeated user steering.
|
||||
runbooks. Its `## User preferences` section is the main actionable payload, but it should
|
||||
still stay compact, deduplicated, and limited to preferences likely to change future behavior.
|
||||
|
||||
============================================================
|
||||
2) `memory_summary.md` FORMAT (STRICT)
|
||||
============================================================
|
||||
|
||||
File header:
|
||||
|
||||
The file must begin exactly:
|
||||
|
||||
```md
|
||||
v1
|
||||
|
||||
## User Profile
|
||||
```
|
||||
|
||||
- The first line must be exactly `v1` with no leading/trailing whitespace and no frontmatter
|
||||
before it.
|
||||
- If the existing `memory_summary.md` first line is not exactly `v1`, discard the old summary
|
||||
structure and regenerate the entire file from the finalized `MEMORY.md`, skills, and current
|
||||
rollout evidence.
|
||||
|
||||
Density objective (strict):
|
||||
|
||||
- `memory_summary.md` is prompt-loaded context, so optimize for high signal per token.
|
||||
- Keep only high-level, cross-task signal and brief routing summaries. Put details, provenance,
|
||||
runbooks, and task-local nuance in `MEMORY.md`, skills, or rollout summaries.
|
||||
- Deduplicate aggressively. If two bullets would cause the same future behavior or route to the
|
||||
same `MEMORY.md` area, merge them or keep the sharper one.
|
||||
- Prefer short, concrete bullets over narrative explanation. Delete low-signal caveats,
|
||||
examples, and historical detail unless they change future agent behavior.
|
||||
- Give directly links to important information to maximize the retrieval efficiency.
|
||||
|
||||
Format:
|
||||
|
||||
## User Profile
|
||||
@@ -474,15 +505,15 @@ For example, include (when known):
|
||||
You may end with short fun facts if they are real and useful, but keep the main profile concrete
|
||||
and grounded. Do not let the optional fun-facts tail make the rest of the section more stylized
|
||||
or abstract.
|
||||
This entire section is free-form, <= 500 words.
|
||||
This entire section is free-form, <= 350 words.
|
||||
|
||||
## User preferences
|
||||
Include a dedicated bullet list of actionable user preferences that are likely to matter again,
|
||||
not just inside one task group.
|
||||
This section should be more concrete and easier to apply than `## User Profile`.
|
||||
Prefer preferences that repeatedly save user keystrokes or avoid predictable interruption.
|
||||
This section may be long. Do not compress it to just a few umbrella bullets when `MEMORY.md`
|
||||
contains many distinct actionable preferences.
|
||||
Keep it dense and non-duplicative. Include only stable or high-leverage preferences that would
|
||||
change future agent behavior across recurring workflows.
|
||||
Treat this as the main actionable payload of `memory_summary.md`.
|
||||
|
||||
For example, include (when known):
|
||||
@@ -500,16 +531,12 @@ Rules:
|
||||
- Keep each bullet actionable and future-facing.
|
||||
- Default to lifting or lightly adapting strong bullets from `MEMORY.md` `## User preferences`
|
||||
rather than rewriting them into smoother higher-level summaries.
|
||||
- Preserve more of the user's original point than a terse summary would. Prefer evidence-aware
|
||||
bullets that still keep some original wording over abstract umbrella summaries.
|
||||
- Preserve the user's original point when it is compact and behavior-changing; otherwise compress
|
||||
to the shortest faithful wording.
|
||||
- When a short quoted or near-verbatim phrase makes the preference easier to recognize or grep
|
||||
for later, keep that phrase in the bullet instead of replacing it with an abstraction.
|
||||
- Do not over-merge adjacent preferences. If several distinct preferences would change different
|
||||
future defaults, keep them as separate bullets.
|
||||
- Prefer many narrow actionable bullets over a few broad umbrella bullets.
|
||||
- Prefer a broad actionable inventory over a short highly deduped list.
|
||||
- Do not treat 5-10 bullets as an implicit target; long-lived memory sets may justify a much
|
||||
longer list.
|
||||
- Merge adjacent preferences unless they would change different future defaults.
|
||||
- Prefer a compact set of sharp bullets over a broad inventory.
|
||||
- Do not require a preference to be broad across task families. If it is likely to matter again
|
||||
in a recurring workflow, it belongs here.
|
||||
- When deciding whether to include a preference, ask whether omitting it would make the next
|
||||
@@ -542,16 +569,20 @@ For example, include (when known):
|
||||
|
||||
This is a compact index to help future agents quickly find details in `MEMORY.md`,
|
||||
`skills/`, and `rollout_summaries/`.
|
||||
Treat it as a routing/index layer, not a mini-handbook:
|
||||
Treat it as a dense routing/index layer, not a mini-handbook:
|
||||
|
||||
- tell future agents what to search first,
|
||||
- preserve enough specificity to route into the right `MEMORY.md` block quickly.
|
||||
- keep topic descriptions brief; delete stale, duplicated, or low-signal topics even if they
|
||||
existed in the previous summary.
|
||||
|
||||
Topic selection and quality rules:
|
||||
|
||||
- Organize the index first by cwd / project scope, then by topic.
|
||||
- Split the index into a recent high-utility window and older topics.
|
||||
- Do not target a fixed topic count. Include informative topics and omit low-signal noise.
|
||||
- Keep the index current. Feel free to restructure, rename, merge, or delete topics when the
|
||||
current `MEMORY.md` organization or evidence has changed.
|
||||
- Prefer grouping by task family / workflow intent, not by incidental tool overlap alone.
|
||||
- Order topics by utility, using `updated_at` recency as a strong default proxy unless there is
|
||||
strong contrary evidence.
|
||||
@@ -588,7 +619,7 @@ Recent Active Memory Window behavior (scope-first, then day-ordered):
|
||||
- Within each scope, order day subsections by recency.
|
||||
- If a scope has only one meaningful recent day, include only that day for that scope.
|
||||
- For each recent-day subsection inside a scope, prioritize informative, likely-to-recur topics and make
|
||||
those entries richer (better keywords, clearer descriptions, and useful recent learnings);
|
||||
those entries denser (better keywords, brief descriptions, and useful recent learnings);
|
||||
do not spend much space on trivial tasks touched that day.
|
||||
- Preserve routing coverage for `MEMORY.md` in the overall index. If a scope/day includes
|
||||
less useful topics, include shorter/compact entries for routing rather than dropping them.
|
||||
@@ -596,16 +627,16 @@ Recent Active Memory Window behavior (scope-first, then day-ordered):
|
||||
appears; do not duplicate it under multiple day sections.
|
||||
- If a topic spans multiple scopes and retrieval would differ by scope, split it. Otherwise,
|
||||
place it under the dominant scope and mention the secondary scope in the description.
|
||||
- Recent-day entries should be richer than older-topic entries: stronger keywords, clearer
|
||||
descriptions, and concise recent learnings/change notes.
|
||||
- Recent-day entries should be more informative than older-topic entries through stronger
|
||||
keywords and concise recent learnings/change notes, not longer prose.
|
||||
- Group similar tasks/topics together when it improves routing clarity.
|
||||
- Do not over cluster topics together, especially when they contain distinct task intents.
|
||||
|
||||
Recent-topic format:
|
||||
|
||||
- <topic>: <keyword1>, <keyword2>, <keyword3>, ...
|
||||
- desc: <clear and specific description of what tasks are inside this topic; what future task/user goal this helps with; what kinds of outcomes/artifacts/procedures are covered; when to search this topic first; preserve original source phrasing when it is a useful retrieval handle; and include explicit cwd applicability text when the work is checkout-sensitive>
|
||||
- learnings: <some concise, topic-local recent takeaways / decision triggers / updates worth checking first; include useful specifics, original source phrasing where possible, and cwd mismatch caveats when important; avoid overlap with `## User preferences` and `## General Tips` (cross-task actionable defaults belong in `## User preferences`; broad reusable guidance belongs in `## General Tips`)>
|
||||
- desc: <brief description of what is inside this topic, when to search it first, and any cwd applicability needed for routing>
|
||||
- learnings: <one dense line of topic-local takeaways / decision triggers / updates worth checking first; avoid overlap with `## User preferences` and `## General Tips`>
|
||||
|
||||
### <cwd / project scope>
|
||||
|
||||
@@ -643,10 +674,9 @@ Notes:
|
||||
caveats, and decision triggers; move cross-task, stable, broadly reusable user defaults to
|
||||
`## User preferences`.
|
||||
- Coverage guardrail: ensure every top-level `# Task Group` in `MEMORY.md` is represented by
|
||||
at least one topic bullet in this index (either directly or via a clearly subsuming topic).
|
||||
- Keep descriptions explicit: what is inside, when to use it, and what kind of
|
||||
outcome/procedure depth is available (for example: runbook, diagnostics, reporting, recovery),
|
||||
so a future agent can quickly choose which topic/keyword cluster to search first.
|
||||
at least one topic bullet in this index (either directly or via a clearly subsuming compact topic).
|
||||
- Keep descriptions explicit but short: enough for a future agent to choose the right
|
||||
topic/keyword cluster, not enough to replace opening `MEMORY.md`.
|
||||
- `memory_summary.md` should not sound like a second-order executive summary. Prefer concrete,
|
||||
source-faithful wording over polished abstraction, especially in:
|
||||
- `## User preferences`
|
||||
@@ -731,6 +761,9 @@ WORKFLOW
|
||||
============================================================
|
||||
|
||||
1. Determine mode (INIT vs INCREMENTAL UPDATE) using artifact availability and current run context.
|
||||
Independently check `memory_summary.md` first line: if it is not exactly `v1`, regenerate
|
||||
`memory_summary.md` from scratch after the other artifacts are finalized, even when `MEMORY.md`
|
||||
itself can be updated incrementally.
|
||||
|
||||
2. INIT phase behavior:
|
||||
- Read `raw_memories.md` first, then rollout summaries carefully.
|
||||
@@ -747,8 +780,8 @@ WORKFLOW
|
||||
conflicting task families until MEMORY blocks are richer and more useful than raw memories
|
||||
|
||||
3. INCREMENTAL UPDATE behavior:
|
||||
- Read existing `MEMORY.md` and `memory_summary.md` first for continuity and to locate
|
||||
existing references that may need surgical cleanup.
|
||||
- Read existing `MEMORY.md` and, only when it starts with exactly `v1`, existing
|
||||
`memory_summary.md` first for continuity and to locate references that may need surgical cleanup.
|
||||
- Use the injected git-style workspace changes as the first routing pass:
|
||||
- added/modified `raw_memories.md` and `rollout_summaries/*.md` = ingestion queue
|
||||
- deleted `rollout_summaries/*.md` and `extensions/*/resources/*.md` = forgetting /
|
||||
@@ -775,6 +808,8 @@ WORKFLOW
|
||||
- doing light clustering and merging if needed
|
||||
- refreshing `MEMORY.md` top-of-file ordering so recent high-utility task families stay easy to find
|
||||
- rebuilding the `memory_summary.md` recent active window (last 3 memory days) from current `updated_at` coverage
|
||||
- freely restructuring `memory_summary.md` so it reflects the current memory set without
|
||||
stale topics, duplicated preference bullets, or obsolete routing labels
|
||||
- updating existing skills or adding new skills only when there is clear new reusable procedure
|
||||
- updating `memory_summary.md` last to reflect the final state of the memory folder
|
||||
- Minimize churn in incremental mode: if an existing `MEMORY.md` block or `## What's in Memory`
|
||||
@@ -816,6 +851,9 @@ WORKFLOW
|
||||
|
||||
7. Final pass:
|
||||
- remove duplication in memory_summary, skills/, and MEMORY.md
|
||||
- verify `memory_summary.md` still begins with exactly `v1`
|
||||
- verify `memory_summary.md` is dense: brief high-level profile, compact actionable
|
||||
preferences, compact general tips, and a routing index rather than a second handbook
|
||||
- remove stale or low-signal blocks that are less likely to be useful in the future
|
||||
- remove or rewrite blocks/task sections whose supporting rollout references point only to
|
||||
deleted inputs or missing rollout summary files
|
||||
|
||||
@@ -121,6 +121,7 @@ pub struct ToolsConfig {
|
||||
pub hide_spawn_agent_metadata: bool,
|
||||
pub spawn_agent_usage_hint: bool,
|
||||
pub spawn_agent_usage_hint_text: Option<String>,
|
||||
pub multi_agent_v2_tool_namespace: Option<String>,
|
||||
pub max_concurrent_threads_per_session: Option<usize>,
|
||||
pub wait_agent_min_timeout_ms: Option<i64>,
|
||||
pub wait_agent_max_timeout_ms: Option<i64>,
|
||||
@@ -260,6 +261,7 @@ impl ToolsConfig {
|
||||
hide_spawn_agent_metadata: false,
|
||||
spawn_agent_usage_hint: true,
|
||||
spawn_agent_usage_hint_text: None,
|
||||
multi_agent_v2_tool_namespace: None,
|
||||
max_concurrent_threads_per_session: None,
|
||||
wait_agent_min_timeout_ms: None,
|
||||
wait_agent_max_timeout_ms: None,
|
||||
@@ -316,6 +318,14 @@ impl ToolsConfig {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_multi_agent_v2_tool_namespace(
|
||||
mut self,
|
||||
multi_agent_v2_tool_namespace: Option<String>,
|
||||
) -> Self {
|
||||
self.multi_agent_v2_tool_namespace = multi_agent_v2_tool_namespace;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_multi_agent_v2_non_code_mode_only(
|
||||
mut self,
|
||||
multi_agent_v2_non_code_mode_only: bool,
|
||||
|
||||
@@ -34,8 +34,9 @@ with Codex() as codex:
|
||||
print(len(result.items))
|
||||
```
|
||||
|
||||
`result.final_response` is `None` when the turn completes without a final-answer
|
||||
or phase-less assistant message item.
|
||||
`thread.run(...)` and `thread.turn(...).run()` return `TurnResult`. Its
|
||||
`final_response` is `None` when the turn completes without a final-answer or
|
||||
phase-less assistant message item.
|
||||
|
||||
## Login
|
||||
|
||||
@@ -101,6 +102,8 @@ target wheel. The SDK package version and runtime package version must match.
|
||||
|
||||
- `Codex()` is eager and performs startup + `initialize` in the constructor.
|
||||
- Use context managers (`with Codex() as codex:`) to ensure shutdown.
|
||||
- Plain strings are accepted anywhere a turn input is accepted; they are
|
||||
shorthand for `TextInput(...)`.
|
||||
- Prefer `thread.run("...")` for the common case. Use `thread.turn(...)` when
|
||||
you need streaming, steering, or interrupt control.
|
||||
- For transient overload, use `retry_on_overload` from the package root.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Public surface of `openai_codex` for app-server v2.
|
||||
|
||||
This SDK surface is experimental. Turn streams are routed by turn ID so one client can consume multiple active turns concurrently.
|
||||
Thread and turn starts expose `approval_mode`. `ApprovalMode.auto_review` is the default; use `ApprovalMode.deny_all` to deny escalated permissions.
|
||||
Thread starts default to `ApprovalMode.auto_review`; turn starts accept an optional `approval_mode` override.
|
||||
|
||||
## Package Entry
|
||||
|
||||
@@ -16,13 +16,14 @@ from openai_codex import (
|
||||
DeviceCodeLoginHandle,
|
||||
AsyncChatgptLoginHandle,
|
||||
AsyncDeviceCodeLoginHandle,
|
||||
RunResult,
|
||||
Thread,
|
||||
AsyncThread,
|
||||
TurnHandle,
|
||||
AsyncTurnHandle,
|
||||
TurnResult,
|
||||
Input,
|
||||
InputItem,
|
||||
RunInput,
|
||||
TextInput,
|
||||
ImageInput,
|
||||
LocalImageInput,
|
||||
@@ -38,6 +39,7 @@ from openai_codex.types import (
|
||||
InitializeResponse,
|
||||
ThreadItem,
|
||||
ThreadTokenUsage,
|
||||
TurnError,
|
||||
TurnStatus,
|
||||
)
|
||||
```
|
||||
@@ -146,16 +148,16 @@ attempt. API-key login completes synchronously and does not return a handle.
|
||||
|
||||
### Thread
|
||||
|
||||
- `run(input: str | Input, *, approval_mode=ApprovalMode.auto_review, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox_policy=None, service_tier=None, summary=None) -> RunResult`
|
||||
- `turn(input: Input, *, approval_mode=ApprovalMode.auto_review, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox_policy=None, summary=None) -> TurnHandle`
|
||||
- `run(input: str | Input, *, approval_mode=None, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox_policy=None, service_tier=None, summary=None) -> TurnResult`
|
||||
- `turn(input: str | Input, *, approval_mode=None, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox_policy=None, service_tier=None, summary=None) -> TurnHandle`
|
||||
- `read(*, include_turns: bool = False) -> ThreadReadResponse`
|
||||
- `set_name(name: str) -> ThreadSetNameResponse`
|
||||
- `compact() -> ThreadCompactStartResponse`
|
||||
|
||||
### AsyncThread
|
||||
|
||||
- `run(input: str | Input, *, approval_mode=ApprovalMode.auto_review, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox_policy=None, service_tier=None, summary=None) -> Awaitable[RunResult]`
|
||||
- `turn(input: Input, *, approval_mode=ApprovalMode.auto_review, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox_policy=None, summary=None) -> Awaitable[AsyncTurnHandle]`
|
||||
- `run(input: str | Input, *, approval_mode=None, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox_policy=None, service_tier=None, summary=None) -> Awaitable[TurnResult]`
|
||||
- `turn(input: str | Input, *, approval_mode=None, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox_policy=None, service_tier=None, summary=None) -> Awaitable[AsyncTurnHandle]`
|
||||
- `read(*, include_turns: bool = False) -> Awaitable[ThreadReadResponse]`
|
||||
- `set_name(name: str) -> Awaitable[ThreadSetNameResponse]`
|
||||
- `compact() -> Awaitable[ThreadCompactStartResponse]`
|
||||
@@ -164,6 +166,12 @@ attempt. API-key login completes synchronously and does not return a handle.
|
||||
the turn, consumes notifications until completion, and returns a small result
|
||||
object with:
|
||||
|
||||
- `id: str`
|
||||
- `status: TurnStatus`
|
||||
- `error: TurnError | None`
|
||||
- `started_at: int | None`
|
||||
- `completed_at: int | None`
|
||||
- `duration_ms: int | None`
|
||||
- `final_response: str | None`
|
||||
- `items: list[ThreadItem]`
|
||||
- `usage: ThreadTokenUsage | None`
|
||||
@@ -172,16 +180,16 @@ object with:
|
||||
phase-less assistant message item.
|
||||
|
||||
Use `turn(...)` when you need low-level turn control (`stream()`, `steer()`,
|
||||
`interrupt()`) or the public `Turn` model from `TurnHandle.run()`.
|
||||
`interrupt()`) before collecting the turn result.
|
||||
|
||||
## TurnHandle / AsyncTurnHandle
|
||||
|
||||
### TurnHandle
|
||||
|
||||
- `steer(input: Input) -> TurnSteerResponse`
|
||||
- `steer(input: str | Input) -> TurnSteerResponse`
|
||||
- `interrupt() -> TurnInterruptResponse`
|
||||
- `stream() -> Iterator[Notification]`
|
||||
- `run() -> openai_codex.types.Turn`
|
||||
- `run() -> TurnResult`
|
||||
|
||||
Behavior notes:
|
||||
|
||||
@@ -190,10 +198,10 @@ Behavior notes:
|
||||
|
||||
### AsyncTurnHandle
|
||||
|
||||
- `steer(input: Input) -> Awaitable[TurnSteerResponse]`
|
||||
- `steer(input: str | Input) -> Awaitable[TurnSteerResponse]`
|
||||
- `interrupt() -> Awaitable[TurnInterruptResponse]`
|
||||
- `stream() -> AsyncIterator[Notification]`
|
||||
- `run() -> Awaitable[openai_codex.types.Turn]`
|
||||
- `run() -> Awaitable[TurnResult]`
|
||||
|
||||
Behavior notes:
|
||||
|
||||
@@ -211,8 +219,12 @@ Behavior notes:
|
||||
|
||||
InputItem = TextInput | ImageInput | LocalImageInput | SkillInput | MentionInput
|
||||
Input = list[InputItem] | InputItem
|
||||
RunInput = Input | str
|
||||
```
|
||||
|
||||
Use a plain `str` as shorthand for `TextInput(...)` anywhere a turn input is accepted:
|
||||
`thread.run("...")`, `thread.turn("...")`, and `turn.steer("...")`.
|
||||
|
||||
## Public Types
|
||||
|
||||
The SDK wrappers return and accept public app-server models wherever possible:
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
|
||||
## `run()` vs `stream()`
|
||||
|
||||
- `TurnHandle.run()` / `AsyncTurnHandle.run()` is the easiest path. It consumes events until completion and returns the public app-server `Turn` model from `openai_codex.types`.
|
||||
- `Thread.run(...)` starts a turn and returns `TurnResult`.
|
||||
- `TurnHandle.run()` / `AsyncTurnHandle.run()` consumes events for an existing turn handle and returns the same `TurnResult` shape.
|
||||
- `TurnHandle.stream()` / `AsyncTurnHandle.stream()` yields raw notifications (`Notification`) so you can react event-by-event.
|
||||
|
||||
Choose `run()` for most apps. Choose `stream()` for progress UIs, custom timeout logic, or custom parsing.
|
||||
@@ -66,7 +67,7 @@ Common causes:
|
||||
|
||||
- published runtime package (`openai-codex-cli-bin`) is not installed
|
||||
- local `codex_bin` override points to a missing file
|
||||
- incompatible/old app-server
|
||||
- app-server version older than the SDK schema
|
||||
|
||||
## Why does a turn "hang"?
|
||||
|
||||
@@ -79,11 +80,11 @@ A turn is complete only when `turn/completed` arrives for that turn ID.
|
||||
|
||||
Use `retry_on_overload(...)` for transient overload failures (`ServerBusyError`).
|
||||
|
||||
Do not blindly retry all errors. For `InvalidParamsError` or `MethodNotFoundError`, fix inputs/version compatibility instead.
|
||||
Do not blindly retry all errors. For `InvalidParamsError` or `MethodNotFoundError`, fix inputs or update the runtime/schema version instead.
|
||||
|
||||
## Common pitfalls
|
||||
|
||||
- Starting a new thread for every prompt when you wanted continuity.
|
||||
- Forgetting to `close()` (or not using context managers).
|
||||
- Assuming `run()` returns extra SDK-only fields instead of the public `Turn` model.
|
||||
- Reading `Turn.items` from live start/completed payloads instead of using `TurnResult.items`.
|
||||
- Mixing SDK input classes with raw dicts incorrectly.
|
||||
|
||||
@@ -70,9 +70,10 @@ What happened:
|
||||
|
||||
- `Codex()` started and initialized `codex app-server`.
|
||||
- `thread_start(...)` created a thread.
|
||||
- `thread.run("...")` started a turn, consumed events until completion, and returned the final assistant response plus collected items and usage.
|
||||
- `thread.run("...")` started a turn, consumed events until completion, and returned `TurnResult` with turn metadata, final assistant response, collected items, and usage.
|
||||
- `result.final_response` is `None` when no final-answer or phase-less assistant message item completes for the turn.
|
||||
- use `thread.turn(...)` when you need a `TurnHandle` for streaming, steering, interrupting, or turn IDs/status
|
||||
- plain strings are accepted anywhere a turn input is accepted; typed inputs are still available for multimodal and structured cases
|
||||
- use `thread.turn(...)` when you need a `TurnHandle` for streaming, steering, or interrupting before collecting `TurnResult`
|
||||
- one client can consume multiple active turns concurrently; turn streams are routed by turn ID
|
||||
|
||||
## 4) Continue the same thread (multi-turn)
|
||||
|
||||
@@ -5,18 +5,13 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import (
|
||||
assistant_text_from_turn,
|
||||
ensure_local_sdk_src,
|
||||
find_turn_by_id,
|
||||
runtime_config,
|
||||
)
|
||||
from _bootstrap import ensure_local_sdk_src, runtime_config
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
import asyncio
|
||||
|
||||
from openai_codex import AsyncCodex, TextInput
|
||||
from openai_codex import AsyncCodex
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
@@ -24,21 +19,16 @@ async def main() -> None:
|
||||
thread = await codex.thread_start(
|
||||
model="gpt-5.4", config={"model_reasoning_effort": "high"}
|
||||
)
|
||||
turn = await thread.turn(TextInput("Give 3 bullets about SIMD."))
|
||||
turn = await thread.turn("Give 3 bullets about SIMD.")
|
||||
result = await turn.run()
|
||||
persisted = await thread.read(include_turns=True)
|
||||
persisted_turn = find_turn_by_id(persisted.thread.turns, result.id)
|
||||
|
||||
print("thread_id:", thread.id)
|
||||
print("turn_id:", result.id)
|
||||
print("status:", result.status)
|
||||
if result.error is not None:
|
||||
print("error:", result.error)
|
||||
print("text:", assistant_text_from_turn(persisted_turn))
|
||||
print(
|
||||
"persisted.items.count:",
|
||||
0 if persisted_turn is None else len(persisted_turn.items or []),
|
||||
)
|
||||
print("text:", result.final_response)
|
||||
print("items.count:", len(result.items))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -5,30 +5,20 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import (
|
||||
assistant_text_from_turn,
|
||||
ensure_local_sdk_src,
|
||||
find_turn_by_id,
|
||||
runtime_config,
|
||||
)
|
||||
from _bootstrap import ensure_local_sdk_src, runtime_config
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
from openai_codex import Codex, TextInput
|
||||
from openai_codex import Codex
|
||||
|
||||
with Codex(config=runtime_config()) as codex:
|
||||
thread = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"})
|
||||
result = thread.turn(TextInput("Give 3 bullets about SIMD.")).run()
|
||||
persisted = thread.read(include_turns=True)
|
||||
persisted_turn = find_turn_by_id(persisted.thread.turns, result.id)
|
||||
result = thread.turn("Give 3 bullets about SIMD.").run()
|
||||
|
||||
print("thread_id:", thread.id)
|
||||
print("turn_id:", result.id)
|
||||
print("status:", result.status)
|
||||
if result.error is not None:
|
||||
print("error:", result.error)
|
||||
print("text:", assistant_text_from_turn(persisted_turn))
|
||||
print(
|
||||
"persisted.items.count:",
|
||||
0 if persisted_turn is None else len(persisted_turn.items or []),
|
||||
)
|
||||
print("text:", result.final_response)
|
||||
print("items.count:", len(result.items))
|
||||
|
||||
@@ -5,18 +5,13 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import (
|
||||
assistant_text_from_turn,
|
||||
ensure_local_sdk_src,
|
||||
find_turn_by_id,
|
||||
runtime_config,
|
||||
)
|
||||
from _bootstrap import ensure_local_sdk_src, runtime_config
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
import asyncio
|
||||
|
||||
from openai_codex import AsyncCodex, TextInput
|
||||
from openai_codex import AsyncCodex
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
@@ -24,12 +19,13 @@ async def main() -> None:
|
||||
thread = await codex.thread_start(
|
||||
model="gpt-5.4", config={"model_reasoning_effort": "high"}
|
||||
)
|
||||
turn = await thread.turn(TextInput("Explain SIMD in 3 short bullets."))
|
||||
turn = await thread.turn("Explain SIMD in 3 short bullets.")
|
||||
|
||||
event_count = 0
|
||||
saw_started = False
|
||||
saw_delta = False
|
||||
completed_status = "unknown"
|
||||
completed_status = None
|
||||
completed_texts = []
|
||||
|
||||
async for event in turn.stream():
|
||||
event_count += 1
|
||||
@@ -38,24 +34,27 @@ async def main() -> None:
|
||||
print("stream.started")
|
||||
continue
|
||||
if event.method == "item/agentMessage/delta":
|
||||
delta = getattr(event.payload, "delta", "")
|
||||
delta = event.payload.delta
|
||||
if delta:
|
||||
if not saw_delta:
|
||||
print("assistant> ", end="", flush=True)
|
||||
print(delta, end="", flush=True)
|
||||
saw_delta = True
|
||||
continue
|
||||
if event.method == "item/completed":
|
||||
root = event.payload.item.root
|
||||
if root.type == "agentMessage":
|
||||
completed_texts.append(root.text)
|
||||
continue
|
||||
if event.method == "turn/completed":
|
||||
completed_status = getattr(
|
||||
event.payload.turn.status, "value", str(event.payload.turn.status)
|
||||
)
|
||||
completed_status = event.payload.turn.status.value
|
||||
|
||||
if completed_status is None:
|
||||
raise RuntimeError("stream ended without turn/completed")
|
||||
if saw_delta:
|
||||
print()
|
||||
else:
|
||||
persisted = await thread.read(include_turns=True)
|
||||
persisted_turn = find_turn_by_id(persisted.thread.turns, turn.id)
|
||||
final_text = assistant_text_from_turn(persisted_turn).strip() or "[no assistant text]"
|
||||
final_text = "".join(completed_texts).strip()
|
||||
print("assistant>", final_text)
|
||||
|
||||
print("stream.started.seen:", saw_started)
|
||||
|
||||
@@ -5,25 +5,21 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import (
|
||||
assistant_text_from_turn,
|
||||
ensure_local_sdk_src,
|
||||
find_turn_by_id,
|
||||
runtime_config,
|
||||
)
|
||||
from _bootstrap import ensure_local_sdk_src, runtime_config
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
from openai_codex import Codex, TextInput
|
||||
from openai_codex import Codex
|
||||
|
||||
with Codex(config=runtime_config()) as codex:
|
||||
thread = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"})
|
||||
turn = thread.turn(TextInput("Explain SIMD in 3 short bullets."))
|
||||
turn = thread.turn("Explain SIMD in 3 short bullets.")
|
||||
|
||||
event_count = 0
|
||||
saw_started = False
|
||||
saw_delta = False
|
||||
completed_status = "unknown"
|
||||
completed_status = None
|
||||
completed_texts = []
|
||||
|
||||
for event in turn.stream():
|
||||
event_count += 1
|
||||
@@ -32,24 +28,27 @@ with Codex(config=runtime_config()) as codex:
|
||||
print("stream.started")
|
||||
continue
|
||||
if event.method == "item/agentMessage/delta":
|
||||
delta = getattr(event.payload, "delta", "")
|
||||
delta = event.payload.delta
|
||||
if delta:
|
||||
if not saw_delta:
|
||||
print("assistant> ", end="", flush=True)
|
||||
print(delta, end="", flush=True)
|
||||
saw_delta = True
|
||||
continue
|
||||
if event.method == "item/completed":
|
||||
root = event.payload.item.root
|
||||
if root.type == "agentMessage":
|
||||
completed_texts.append(root.text)
|
||||
continue
|
||||
if event.method == "turn/completed":
|
||||
completed_status = getattr(
|
||||
event.payload.turn.status, "value", str(event.payload.turn.status)
|
||||
)
|
||||
completed_status = event.payload.turn.status.value
|
||||
|
||||
if completed_status is None:
|
||||
raise RuntimeError("stream ended without turn/completed")
|
||||
if saw_delta:
|
||||
print()
|
||||
else:
|
||||
persisted = thread.read(include_turns=True)
|
||||
persisted_turn = find_turn_by_id(persisted.thread.turns, turn.id)
|
||||
final_text = assistant_text_from_turn(persisted_turn).strip() or "[no assistant text]"
|
||||
final_text = "".join(completed_texts).strip()
|
||||
print("assistant>", final_text)
|
||||
|
||||
print("stream.started.seen:", saw_started)
|
||||
|
||||
@@ -19,7 +19,7 @@ async def main() -> None:
|
||||
print("server:", server_label(codex.metadata))
|
||||
models = await codex.models()
|
||||
print("models.count:", len(models.data))
|
||||
print("models:", ", ".join(model.id for model in models.data[:5]) or "[none]")
|
||||
print("models:", ", ".join(model.id for model in models.data[:5]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -15,4 +15,4 @@ with Codex(config=runtime_config()) as codex:
|
||||
print("server:", server_label(codex.metadata))
|
||||
models = codex.models()
|
||||
print("models.count:", len(models.data))
|
||||
print("models:", ", ".join(model.id for model in models.data[:5]) or "[none]")
|
||||
print("models:", ", ".join(model.id for model in models.data[:5]))
|
||||
|
||||
@@ -5,18 +5,13 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import (
|
||||
assistant_text_from_turn,
|
||||
ensure_local_sdk_src,
|
||||
find_turn_by_id,
|
||||
runtime_config,
|
||||
)
|
||||
from _bootstrap import ensure_local_sdk_src, runtime_config
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
import asyncio
|
||||
|
||||
from openai_codex import AsyncCodex, TextInput
|
||||
from openai_codex import AsyncCodex
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
@@ -25,16 +20,14 @@ async def main() -> None:
|
||||
model="gpt-5.4", config={"model_reasoning_effort": "high"}
|
||||
)
|
||||
|
||||
first_turn = await original.turn(TextInput("Tell me one fact about Saturn."))
|
||||
first_turn = await original.turn("Tell me one fact about Saturn.")
|
||||
_ = await first_turn.run()
|
||||
print("Created thread:", original.id)
|
||||
|
||||
resumed = await codex.thread_resume(original.id)
|
||||
second_turn = await resumed.turn(TextInput("Continue with one more fact."))
|
||||
second_turn = await resumed.turn("Continue with one more fact.")
|
||||
second = await second_turn.run()
|
||||
persisted = await resumed.read(include_turns=True)
|
||||
persisted_turn = find_turn_by_id(persisted.thread.turns, second.id)
|
||||
print(assistant_text_from_turn(persisted_turn))
|
||||
print(second.final_response)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -5,26 +5,19 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import (
|
||||
assistant_text_from_turn,
|
||||
ensure_local_sdk_src,
|
||||
find_turn_by_id,
|
||||
runtime_config,
|
||||
)
|
||||
from _bootstrap import ensure_local_sdk_src, runtime_config
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
from openai_codex import Codex, TextInput
|
||||
from openai_codex import Codex
|
||||
|
||||
with Codex(config=runtime_config()) as codex:
|
||||
# Create an initial thread and turn so we have a real thread to resume.
|
||||
original = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"})
|
||||
first = original.turn(TextInput("Tell me one fact about Saturn.")).run()
|
||||
first = original.turn("Tell me one fact about Saturn.").run()
|
||||
print("Created thread:", original.id)
|
||||
|
||||
# Resume the existing thread by ID.
|
||||
resumed = codex.thread_resume(original.id)
|
||||
second = resumed.turn(TextInput("Continue with one more fact.")).run()
|
||||
persisted = resumed.read(include_turns=True)
|
||||
persisted_turn = find_turn_by_id(persisted.thread.turns, second.id)
|
||||
print(assistant_text_from_turn(persisted_turn))
|
||||
second = resumed.turn("Continue with one more fact.").run()
|
||||
print(second.final_response)
|
||||
|
||||
@@ -11,7 +11,7 @@ ensure_local_sdk_src()
|
||||
|
||||
import asyncio
|
||||
|
||||
from openai_codex import AsyncCodex, TextInput
|
||||
from openai_codex import AsyncCodex
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
@@ -19,10 +19,8 @@ async def main() -> None:
|
||||
thread = await codex.thread_start(
|
||||
model="gpt-5.4", config={"model_reasoning_effort": "high"}
|
||||
)
|
||||
first = await (
|
||||
await thread.turn(TextInput("One sentence about structured planning."))
|
||||
).run()
|
||||
second = await (await thread.turn(TextInput("Now restate it for a junior engineer."))).run()
|
||||
first = await (await thread.turn("One sentence about structured planning.")).run()
|
||||
second = await (await thread.turn("Now restate it for a junior engineer.")).run()
|
||||
|
||||
reopened = await codex.thread_resume(thread.id)
|
||||
listing_active = await codex.thread_list(limit=20, archived=False)
|
||||
@@ -33,45 +31,29 @@ async def main() -> None:
|
||||
listing_archived = await codex.thread_list(limit=20, archived=True)
|
||||
unarchived = await codex.thread_unarchive(reopened.id)
|
||||
|
||||
resumed_info = "n/a"
|
||||
try:
|
||||
resumed = await codex.thread_resume(
|
||||
unarchived.id,
|
||||
model="gpt-5.4",
|
||||
config={"model_reasoning_effort": "high"},
|
||||
)
|
||||
resumed_result = await (
|
||||
await resumed.turn(TextInput("Continue in one short sentence."))
|
||||
).run()
|
||||
resumed_info = f"{resumed_result.id} {resumed_result.status}"
|
||||
except Exception as exc:
|
||||
resumed_info = f"skipped({type(exc).__name__})"
|
||||
resumed = await codex.thread_resume(
|
||||
unarchived.id,
|
||||
model="gpt-5.4",
|
||||
config={"model_reasoning_effort": "high"},
|
||||
)
|
||||
resumed_result = await (await resumed.turn("Continue in one short sentence.")).run()
|
||||
|
||||
forked_info = "n/a"
|
||||
try:
|
||||
forked = await codex.thread_fork(unarchived.id, model="gpt-5.4")
|
||||
forked_result = await (
|
||||
await forked.turn(TextInput("Take a different angle in one short sentence."))
|
||||
).run()
|
||||
forked_info = f"{forked_result.id} {forked_result.status}"
|
||||
except Exception as exc:
|
||||
forked_info = f"skipped({type(exc).__name__})"
|
||||
forked = await codex.thread_fork(unarchived.id, model="gpt-5.4")
|
||||
forked_result = await (
|
||||
await forked.turn("Take a different angle in one short sentence.")
|
||||
).run()
|
||||
|
||||
compact_info = "sent"
|
||||
try:
|
||||
_ = await unarchived.compact()
|
||||
except Exception as exc:
|
||||
compact_info = f"skipped({type(exc).__name__})"
|
||||
compact_result = await unarchived.compact()
|
||||
|
||||
print("Lifecycle OK:", thread.id)
|
||||
print("first:", first.id, first.status)
|
||||
print("second:", second.id, second.status)
|
||||
print("read.turns:", len(reading.thread.turns or []))
|
||||
print("read.turns:", len(reading.thread.turns))
|
||||
print("list.active:", len(listing_active.data))
|
||||
print("list.archived:", len(listing_archived.data))
|
||||
print("resumed:", resumed_info)
|
||||
print("forked:", forked_info)
|
||||
print("compact:", compact_info)
|
||||
print("resumed:", resumed_result.id, resumed_result.status)
|
||||
print("forked:", forked_result.id, forked_result.status)
|
||||
print("compact:", compact_result.model_dump(mode="json", by_alias=True))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -9,12 +9,12 @@ from _bootstrap import ensure_local_sdk_src, runtime_config
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
from openai_codex import Codex, TextInput
|
||||
from openai_codex import Codex
|
||||
|
||||
with Codex(config=runtime_config()) as codex:
|
||||
thread = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"})
|
||||
first = thread.turn(TextInput("One sentence about structured planning.")).run()
|
||||
second = thread.turn(TextInput("Now restate it for a junior engineer.")).run()
|
||||
first = thread.turn("One sentence about structured planning.").run()
|
||||
second = thread.turn("Now restate it for a junior engineer.").run()
|
||||
|
||||
reopened = codex.thread_resume(thread.id)
|
||||
listing_active = codex.thread_list(limit=20, archived=False)
|
||||
@@ -25,40 +25,24 @@ with Codex(config=runtime_config()) as codex:
|
||||
listing_archived = codex.thread_list(limit=20, archived=True)
|
||||
unarchived = codex.thread_unarchive(reopened.id)
|
||||
|
||||
resumed_info = "n/a"
|
||||
try:
|
||||
resumed = codex.thread_resume(
|
||||
unarchived.id,
|
||||
model="gpt-5.4",
|
||||
config={"model_reasoning_effort": "high"},
|
||||
)
|
||||
resumed_result = resumed.turn(TextInput("Continue in one short sentence.")).run()
|
||||
resumed_info = f"{resumed_result.id} {resumed_result.status}"
|
||||
except Exception as exc:
|
||||
resumed_info = f"skipped({type(exc).__name__})"
|
||||
resumed = codex.thread_resume(
|
||||
unarchived.id,
|
||||
model="gpt-5.4",
|
||||
config={"model_reasoning_effort": "high"},
|
||||
)
|
||||
resumed_result = resumed.turn("Continue in one short sentence.").run()
|
||||
|
||||
forked_info = "n/a"
|
||||
try:
|
||||
forked = codex.thread_fork(unarchived.id, model="gpt-5.4")
|
||||
forked_result = forked.turn(
|
||||
TextInput("Take a different angle in one short sentence.")
|
||||
).run()
|
||||
forked_info = f"{forked_result.id} {forked_result.status}"
|
||||
except Exception as exc:
|
||||
forked_info = f"skipped({type(exc).__name__})"
|
||||
forked = codex.thread_fork(unarchived.id, model="gpt-5.4")
|
||||
forked_result = forked.turn("Take a different angle in one short sentence.").run()
|
||||
|
||||
compact_info = "sent"
|
||||
try:
|
||||
_ = unarchived.compact()
|
||||
except Exception as exc:
|
||||
compact_info = f"skipped({type(exc).__name__})"
|
||||
compact_result = unarchived.compact()
|
||||
|
||||
print("Lifecycle OK:", thread.id)
|
||||
print("first:", first.id, first.status)
|
||||
print("second:", second.id, second.status)
|
||||
print("read.turns:", len(reading.thread.turns or []))
|
||||
print("read.turns:", len(reading.thread.turns))
|
||||
print("list.active:", len(listing_active.data))
|
||||
print("list.archived:", len(listing_archived.data))
|
||||
print("resumed:", resumed_info)
|
||||
print("forked:", forked_info)
|
||||
print("compact:", compact_info)
|
||||
print("resumed:", resumed_result.id, resumed_result.status)
|
||||
print("forked:", forked_result.id, forked_result.status)
|
||||
print("compact:", compact_result.model_dump(mode="json", by_alias=True))
|
||||
|
||||
@@ -5,12 +5,7 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import (
|
||||
assistant_text_from_turn,
|
||||
ensure_local_sdk_src,
|
||||
find_turn_by_id,
|
||||
runtime_config,
|
||||
)
|
||||
from _bootstrap import ensure_local_sdk_src, runtime_config
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
@@ -33,11 +28,9 @@ async def main() -> None:
|
||||
]
|
||||
)
|
||||
result = await turn.run()
|
||||
persisted = await thread.read(include_turns=True)
|
||||
persisted_turn = find_turn_by_id(persisted.thread.turns, result.id)
|
||||
|
||||
print("Status:", result.status)
|
||||
print(assistant_text_from_turn(persisted_turn))
|
||||
print(result.final_response)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -5,12 +5,7 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import (
|
||||
assistant_text_from_turn,
|
||||
ensure_local_sdk_src,
|
||||
find_turn_by_id,
|
||||
runtime_config,
|
||||
)
|
||||
from _bootstrap import ensure_local_sdk_src, runtime_config
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
@@ -26,8 +21,6 @@ with Codex(config=runtime_config()) as codex:
|
||||
ImageInput(REMOTE_IMAGE_URL),
|
||||
]
|
||||
).run()
|
||||
persisted = thread.read(include_turns=True)
|
||||
persisted_turn = find_turn_by_id(persisted.thread.turns, result.id)
|
||||
|
||||
print("Status:", result.status)
|
||||
print(assistant_text_from_turn(persisted_turn))
|
||||
print(result.final_response)
|
||||
|
||||
@@ -6,9 +6,7 @@ if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import (
|
||||
assistant_text_from_turn,
|
||||
ensure_local_sdk_src,
|
||||
find_turn_by_id,
|
||||
runtime_config,
|
||||
temporary_sample_image_path,
|
||||
)
|
||||
@@ -36,11 +34,9 @@ async def main() -> None:
|
||||
]
|
||||
)
|
||||
result = await turn.run()
|
||||
persisted = await thread.read(include_turns=True)
|
||||
persisted_turn = find_turn_by_id(persisted.thread.turns, result.id)
|
||||
|
||||
print("Status:", result.status)
|
||||
print(assistant_text_from_turn(persisted_turn))
|
||||
print(result.final_response)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -6,9 +6,7 @@ if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import (
|
||||
assistant_text_from_turn,
|
||||
ensure_local_sdk_src,
|
||||
find_turn_by_id,
|
||||
runtime_config,
|
||||
temporary_sample_image_path,
|
||||
)
|
||||
@@ -29,8 +27,6 @@ with temporary_sample_image_path() as image_path:
|
||||
LocalImageInput(str(image_path.resolve())),
|
||||
]
|
||||
).run()
|
||||
persisted = thread.read(include_turns=True)
|
||||
persisted_turn = find_turn_by_id(persisted.thread.turns, result.id)
|
||||
|
||||
print("Status:", result.status)
|
||||
print(assistant_text_from_turn(persisted_turn))
|
||||
print(result.final_response)
|
||||
|
||||
@@ -5,27 +5,19 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import (
|
||||
assistant_text_from_turn,
|
||||
ensure_local_sdk_src,
|
||||
find_turn_by_id,
|
||||
runtime_config,
|
||||
server_label,
|
||||
)
|
||||
from _bootstrap import ensure_local_sdk_src, runtime_config, server_label
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
from openai_codex import Codex, TextInput
|
||||
from openai_codex import Codex
|
||||
|
||||
with Codex(config=runtime_config()) as codex:
|
||||
print("Server:", server_label(codex.metadata))
|
||||
|
||||
thread = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"})
|
||||
turn = thread.turn(TextInput("Say hello in one sentence."))
|
||||
turn = thread.turn("Say hello in one sentence.")
|
||||
result = turn.run()
|
||||
persisted = thread.read(include_turns=True)
|
||||
persisted_turn = find_turn_by_id(persisted.thread.turns, result.id)
|
||||
|
||||
print("Thread:", thread.id)
|
||||
print("Turn:", result.id)
|
||||
print("Text:", assistant_text_from_turn(persisted_turn).strip())
|
||||
print("Text:", result.final_response.strip())
|
||||
|
||||
@@ -5,12 +5,7 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import (
|
||||
assistant_text_from_turn,
|
||||
ensure_local_sdk_src,
|
||||
find_turn_by_id,
|
||||
runtime_config,
|
||||
)
|
||||
from _bootstrap import ensure_local_sdk_src, runtime_config
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
@@ -23,7 +18,6 @@ from openai_codex import (
|
||||
AsyncCodex,
|
||||
JsonRpcError,
|
||||
ServerBusyError,
|
||||
TextInput,
|
||||
is_retryable_error,
|
||||
)
|
||||
from openai_codex.types import TurnStatus
|
||||
@@ -73,24 +67,21 @@ async def main() -> None:
|
||||
)
|
||||
except ServerBusyError as exc:
|
||||
print("Server overloaded after retries:", exc.message)
|
||||
print("Text:")
|
||||
return
|
||||
except JsonRpcError as exc:
|
||||
print(f"JSON-RPC error {exc.code}: {exc.message}")
|
||||
print("Text:")
|
||||
return
|
||||
|
||||
persisted = await thread.read(include_turns=True)
|
||||
persisted_turn = find_turn_by_id(persisted.thread.turns, result.id)
|
||||
if result.status == TurnStatus.failed:
|
||||
print("Turn failed:", result.error)
|
||||
return
|
||||
|
||||
print("Text:", assistant_text_from_turn(persisted_turn))
|
||||
print("Text:", result.final_response)
|
||||
|
||||
|
||||
def _run_turn(thread, prompt: str):
|
||||
async def _inner():
|
||||
turn = await thread.turn(TextInput(prompt))
|
||||
turn = await thread.turn(prompt)
|
||||
return await turn.run()
|
||||
|
||||
return _inner
|
||||
|
||||
@@ -5,12 +5,7 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import (
|
||||
assistant_text_from_turn,
|
||||
ensure_local_sdk_src,
|
||||
find_turn_by_id,
|
||||
runtime_config,
|
||||
)
|
||||
from _bootstrap import ensure_local_sdk_src, runtime_config
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
@@ -18,7 +13,6 @@ from openai_codex import (
|
||||
Codex,
|
||||
JsonRpcError,
|
||||
ServerBusyError,
|
||||
TextInput,
|
||||
retry_on_overload,
|
||||
)
|
||||
from openai_codex.types import TurnStatus
|
||||
@@ -28,20 +22,17 @@ with Codex(config=runtime_config()) as codex:
|
||||
|
||||
try:
|
||||
result = retry_on_overload(
|
||||
lambda: thread.turn(TextInput("Summarize retry best practices in 3 bullets.")).run(),
|
||||
lambda: thread.turn("Summarize retry best practices in 3 bullets.").run(),
|
||||
max_attempts=3,
|
||||
initial_delay_s=0.25,
|
||||
max_delay_s=2.0,
|
||||
)
|
||||
except ServerBusyError as exc:
|
||||
print("Server overloaded after retries:", exc.message)
|
||||
print("Text:")
|
||||
except JsonRpcError as exc:
|
||||
print(f"JSON-RPC error {exc.code}: {exc.message}")
|
||||
print("Text:")
|
||||
else:
|
||||
persisted = thread.read(include_turns=True)
|
||||
persisted_turn = find_turn_by_id(persisted.thread.turns, result.id)
|
||||
if result.status == TurnStatus.failed:
|
||||
print("Turn failed:", result.error)
|
||||
print("Text:", assistant_text_from_turn(persisted_turn))
|
||||
else:
|
||||
print("Text:", result.final_response)
|
||||
|
||||
@@ -13,7 +13,6 @@ import asyncio
|
||||
|
||||
from openai_codex import (
|
||||
AsyncCodex,
|
||||
TextInput,
|
||||
)
|
||||
from openai_codex.types import (
|
||||
ThreadTokenUsageUpdatedNotification,
|
||||
@@ -21,19 +20,9 @@ from openai_codex.types import (
|
||||
)
|
||||
|
||||
|
||||
def _status_value(status: object | None) -> str:
|
||||
return str(getattr(status, "value", status))
|
||||
|
||||
|
||||
def _format_usage(usage: object | None) -> str:
|
||||
if usage is None:
|
||||
return "usage> (none)"
|
||||
|
||||
last = getattr(usage, "last", None)
|
||||
total = getattr(usage, "total", None)
|
||||
if last is None or total is None:
|
||||
return f"usage> {usage}"
|
||||
|
||||
def _format_usage(usage: object) -> str:
|
||||
last = usage.last
|
||||
total = usage.total
|
||||
return (
|
||||
"usage>\n"
|
||||
f" last: input={last.input_tokens} output={last.output_tokens} reasoning={last.reasoning_output_tokens} total={last.total_tokens} cached={last.cached_input_tokens}\n"
|
||||
@@ -61,20 +50,18 @@ async def main() -> None:
|
||||
if user_input in {"/exit", "/quit"}:
|
||||
break
|
||||
|
||||
turn = await thread.turn(TextInput(user_input))
|
||||
turn = await thread.turn(user_input)
|
||||
usage = None
|
||||
status = None
|
||||
error = None
|
||||
printed_delta = False
|
||||
|
||||
print("assistant> ", end="", flush=True)
|
||||
async for event in turn.stream():
|
||||
payload = event.payload
|
||||
if event.method == "item/agentMessage/delta":
|
||||
delta = getattr(payload, "delta", "")
|
||||
delta = payload.delta
|
||||
if delta:
|
||||
print(delta, end="", flush=True)
|
||||
printed_delta = True
|
||||
continue
|
||||
if isinstance(payload, ThreadTokenUsageUpdatedNotification):
|
||||
usage = payload.token_usage
|
||||
@@ -83,12 +70,13 @@ async def main() -> None:
|
||||
status = payload.turn.status
|
||||
error = payload.turn.error
|
||||
|
||||
if printed_delta:
|
||||
print()
|
||||
else:
|
||||
print("[no text]")
|
||||
print()
|
||||
if status is None:
|
||||
raise RuntimeError("stream ended without turn/completed")
|
||||
if usage is None:
|
||||
raise RuntimeError("stream ended without token usage")
|
||||
|
||||
status_text = _status_value(status)
|
||||
status_text = status.value
|
||||
print(f"assistant.status> {status_text}")
|
||||
if status_text == "failed":
|
||||
print("assistant.error>", error)
|
||||
|
||||
@@ -11,7 +11,6 @@ ensure_local_sdk_src()
|
||||
|
||||
from openai_codex import (
|
||||
Codex,
|
||||
TextInput,
|
||||
)
|
||||
from openai_codex.types import (
|
||||
ThreadTokenUsageUpdatedNotification,
|
||||
@@ -21,19 +20,9 @@ from openai_codex.types import (
|
||||
print("Codex mini CLI. Type /exit to quit.")
|
||||
|
||||
|
||||
def _status_value(status: object | None) -> str:
|
||||
return str(getattr(status, "value", status))
|
||||
|
||||
|
||||
def _format_usage(usage: object | None) -> str:
|
||||
if usage is None:
|
||||
return "usage> (none)"
|
||||
|
||||
last = getattr(usage, "last", None)
|
||||
total = getattr(usage, "total", None)
|
||||
if last is None or total is None:
|
||||
return f"usage> {usage}"
|
||||
|
||||
def _format_usage(usage: object) -> str:
|
||||
last = usage.last
|
||||
total = usage.total
|
||||
return (
|
||||
"usage>\n"
|
||||
f" last: input={last.input_tokens} output={last.output_tokens} reasoning={last.reasoning_output_tokens} total={last.total_tokens} cached={last.cached_input_tokens}\n"
|
||||
@@ -56,20 +45,18 @@ with Codex(config=runtime_config()) as codex:
|
||||
if user_input in {"/exit", "/quit"}:
|
||||
break
|
||||
|
||||
turn = thread.turn(TextInput(user_input))
|
||||
turn = thread.turn(user_input)
|
||||
usage = None
|
||||
status = None
|
||||
error = None
|
||||
printed_delta = False
|
||||
|
||||
print("assistant> ", end="", flush=True)
|
||||
for event in turn.stream():
|
||||
payload = event.payload
|
||||
if event.method == "item/agentMessage/delta":
|
||||
delta = getattr(payload, "delta", "")
|
||||
delta = payload.delta
|
||||
if delta:
|
||||
print(delta, end="", flush=True)
|
||||
printed_delta = True
|
||||
continue
|
||||
if isinstance(payload, ThreadTokenUsageUpdatedNotification):
|
||||
usage = payload.token_usage
|
||||
@@ -78,12 +65,13 @@ with Codex(config=runtime_config()) as codex:
|
||||
status = payload.turn.status
|
||||
error = payload.turn.error
|
||||
|
||||
if printed_delta:
|
||||
print()
|
||||
else:
|
||||
print("[no text]")
|
||||
print()
|
||||
if status is None:
|
||||
raise RuntimeError("stream ended without turn/completed")
|
||||
if usage is None:
|
||||
raise RuntimeError("stream ended without token usage")
|
||||
|
||||
status_text = _status_value(status)
|
||||
status_text = status.value
|
||||
print(f"assistant.status> {status_text}")
|
||||
if status_text == "failed":
|
||||
print("assistant.error>", error)
|
||||
|
||||
@@ -6,12 +6,7 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import (
|
||||
assistant_text_from_turn,
|
||||
ensure_local_sdk_src,
|
||||
find_turn_by_id,
|
||||
runtime_config,
|
||||
)
|
||||
from _bootstrap import ensure_local_sdk_src, runtime_config
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
@@ -19,7 +14,6 @@ import asyncio
|
||||
|
||||
from openai_codex import (
|
||||
AsyncCodex,
|
||||
TextInput,
|
||||
)
|
||||
from openai_codex.types import (
|
||||
Personality,
|
||||
@@ -54,15 +48,13 @@ async def main() -> None:
|
||||
)
|
||||
|
||||
turn = await thread.turn(
|
||||
TextInput(PROMPT),
|
||||
PROMPT,
|
||||
output_schema=OUTPUT_SCHEMA,
|
||||
personality=Personality.pragmatic,
|
||||
summary=SUMMARY,
|
||||
)
|
||||
result = await turn.run()
|
||||
persisted = await thread.read(include_turns=True)
|
||||
persisted_turn = find_turn_by_id(persisted.thread.turns, result.id)
|
||||
structured_text = assistant_text_from_turn(persisted_turn).strip()
|
||||
structured_text = result.final_response.strip()
|
||||
try:
|
||||
structured = json.loads(structured_text)
|
||||
except json.JSONDecodeError as exc:
|
||||
@@ -70,8 +62,8 @@ async def main() -> None:
|
||||
f"Expected JSON matching OUTPUT_SCHEMA, got: {structured_text!r}"
|
||||
) from exc
|
||||
|
||||
summary = structured.get("summary")
|
||||
actions = structured.get("actions")
|
||||
summary = structured["summary"]
|
||||
actions = structured["actions"]
|
||||
if (
|
||||
not isinstance(summary, str)
|
||||
or not isinstance(actions, list)
|
||||
@@ -86,7 +78,7 @@ async def main() -> None:
|
||||
print("actions:")
|
||||
for action in actions:
|
||||
print("-", action)
|
||||
print("Items:", 0 if persisted_turn is None else len(persisted_turn.items or []))
|
||||
print("Items:", len(result.items))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -6,18 +6,12 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import (
|
||||
assistant_text_from_turn,
|
||||
ensure_local_sdk_src,
|
||||
find_turn_by_id,
|
||||
runtime_config,
|
||||
)
|
||||
from _bootstrap import ensure_local_sdk_src, runtime_config
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
from openai_codex import (
|
||||
Codex,
|
||||
TextInput,
|
||||
)
|
||||
from openai_codex.types import (
|
||||
Personality,
|
||||
@@ -48,15 +42,13 @@ with Codex(config=runtime_config()) as codex:
|
||||
thread = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"})
|
||||
|
||||
turn = thread.turn(
|
||||
TextInput(PROMPT),
|
||||
PROMPT,
|
||||
output_schema=OUTPUT_SCHEMA,
|
||||
personality=Personality.pragmatic,
|
||||
summary=SUMMARY,
|
||||
)
|
||||
result = turn.run()
|
||||
persisted = thread.read(include_turns=True)
|
||||
persisted_turn = find_turn_by_id(persisted.thread.turns, result.id)
|
||||
structured_text = assistant_text_from_turn(persisted_turn).strip()
|
||||
structured_text = result.final_response.strip()
|
||||
try:
|
||||
structured = json.loads(structured_text)
|
||||
except json.JSONDecodeError as exc:
|
||||
@@ -64,8 +56,8 @@ with Codex(config=runtime_config()) as codex:
|
||||
f"Expected JSON matching OUTPUT_SCHEMA, got: {structured_text!r}"
|
||||
) from exc
|
||||
|
||||
summary = structured.get("summary")
|
||||
actions = structured.get("actions")
|
||||
summary = structured["summary"]
|
||||
actions = structured["actions"]
|
||||
if (
|
||||
not isinstance(summary, str)
|
||||
or not isinstance(actions, list)
|
||||
@@ -80,4 +72,4 @@ with Codex(config=runtime_config()) as codex:
|
||||
print("actions:")
|
||||
for action in actions:
|
||||
print("-", action)
|
||||
print("Items:", 0 if persisted_turn is None else len(persisted_turn.items or []))
|
||||
print("Items:", len(result.items))
|
||||
|
||||
@@ -5,12 +5,7 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import (
|
||||
assistant_text_from_turn,
|
||||
ensure_local_sdk_src,
|
||||
find_turn_by_id,
|
||||
runtime_config,
|
||||
)
|
||||
from _bootstrap import ensure_local_sdk_src, runtime_config
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
@@ -18,7 +13,6 @@ import asyncio
|
||||
|
||||
from openai_codex import (
|
||||
AsyncCodex,
|
||||
TextInput,
|
||||
)
|
||||
from openai_codex.types import (
|
||||
Personality,
|
||||
@@ -35,29 +29,27 @@ REASONING_RANK = {
|
||||
"high": 4,
|
||||
"xhigh": 5,
|
||||
}
|
||||
PREFERRED_MODEL = "gpt-5.4"
|
||||
|
||||
|
||||
def _pick_highest_model(models):
|
||||
visible = [m for m in models if not m.hidden] or models
|
||||
preferred = next(
|
||||
(m for m in visible if m.model == PREFERRED_MODEL or m.id == PREFERRED_MODEL), None
|
||||
)
|
||||
if preferred is not None:
|
||||
return preferred
|
||||
visible = [m for m in models if not m.hidden]
|
||||
if not visible:
|
||||
raise RuntimeError("models response did not include visible models")
|
||||
|
||||
known_names = {m.id for m in visible} | {m.model for m in visible}
|
||||
top_candidates = [m for m in visible if not (m.upgrade and m.upgrade in known_names)]
|
||||
pool = top_candidates or visible
|
||||
return max(pool, key=lambda m: (m.model, m.id))
|
||||
if not top_candidates:
|
||||
raise RuntimeError("models response did not include top-level visible models")
|
||||
return max(top_candidates, key=lambda m: (m.model, m.id))
|
||||
|
||||
|
||||
def _pick_highest_turn_effort(model) -> ReasoningEffort:
|
||||
if not model.supported_reasoning_efforts:
|
||||
return ReasoningEffort.medium
|
||||
raise RuntimeError(f"{model.model} did not advertise supported reasoning efforts")
|
||||
|
||||
best = max(
|
||||
model.supported_reasoning_efforts,
|
||||
key=lambda option: REASONING_RANK.get(option.reasoning_effort.value, -1),
|
||||
key=lambda option: REASONING_RANK[option.reasoning_effort.value],
|
||||
)
|
||||
return ReasoningEffort(best.reasoning_effort.value)
|
||||
|
||||
@@ -98,21 +90,17 @@ async def main() -> None:
|
||||
)
|
||||
|
||||
first_turn = await thread.turn(
|
||||
TextInput("Give one short sentence about reliable production releases."),
|
||||
"Give one short sentence about reliable production releases.",
|
||||
model=selected_model.model,
|
||||
effort=selected_effort,
|
||||
)
|
||||
first = await first_turn.run()
|
||||
persisted = await thread.read(include_turns=True)
|
||||
first_persisted_turn = find_turn_by_id(persisted.thread.turns, first.id)
|
||||
|
||||
print("agent.message:", assistant_text_from_turn(first_persisted_turn))
|
||||
print(
|
||||
"items:", 0 if first_persisted_turn is None else len(first_persisted_turn.items or [])
|
||||
)
|
||||
print("agent.message:", first.final_response)
|
||||
print("items:", len(first.items))
|
||||
|
||||
second_turn = await thread.turn(
|
||||
TextInput("Return JSON for a safe feature-flag rollout plan."),
|
||||
"Return JSON for a safe feature-flag rollout plan.",
|
||||
cwd=str(Path.cwd()),
|
||||
effort=selected_effort,
|
||||
model=selected_model.model,
|
||||
@@ -122,14 +110,9 @@ async def main() -> None:
|
||||
summary=ReasoningSummary.model_validate("concise"),
|
||||
)
|
||||
second = await second_turn.run()
|
||||
persisted = await thread.read(include_turns=True)
|
||||
second_persisted_turn = find_turn_by_id(persisted.thread.turns, second.id)
|
||||
|
||||
print("agent.message.params:", assistant_text_from_turn(second_persisted_turn))
|
||||
print(
|
||||
"items.params:",
|
||||
0 if second_persisted_turn is None else len(second_persisted_turn.items or []),
|
||||
)
|
||||
print("agent.message.params:", second.final_response)
|
||||
print("items.params:", len(second.items))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -5,18 +5,12 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import (
|
||||
assistant_text_from_turn,
|
||||
ensure_local_sdk_src,
|
||||
find_turn_by_id,
|
||||
runtime_config,
|
||||
)
|
||||
from _bootstrap import ensure_local_sdk_src, runtime_config
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
from openai_codex import (
|
||||
Codex,
|
||||
TextInput,
|
||||
)
|
||||
from openai_codex.types import (
|
||||
Personality,
|
||||
@@ -33,29 +27,27 @@ REASONING_RANK = {
|
||||
"high": 4,
|
||||
"xhigh": 5,
|
||||
}
|
||||
PREFERRED_MODEL = "gpt-5.4"
|
||||
|
||||
|
||||
def _pick_highest_model(models):
|
||||
visible = [m for m in models if not m.hidden] or models
|
||||
preferred = next(
|
||||
(m for m in visible if m.model == PREFERRED_MODEL or m.id == PREFERRED_MODEL), None
|
||||
)
|
||||
if preferred is not None:
|
||||
return preferred
|
||||
visible = [m for m in models if not m.hidden]
|
||||
if not visible:
|
||||
raise RuntimeError("models response did not include visible models")
|
||||
|
||||
known_names = {m.id for m in visible} | {m.model for m in visible}
|
||||
top_candidates = [m for m in visible if not (m.upgrade and m.upgrade in known_names)]
|
||||
pool = top_candidates or visible
|
||||
return max(pool, key=lambda m: (m.model, m.id))
|
||||
if not top_candidates:
|
||||
raise RuntimeError("models response did not include top-level visible models")
|
||||
return max(top_candidates, key=lambda m: (m.model, m.id))
|
||||
|
||||
|
||||
def _pick_highest_turn_effort(model) -> ReasoningEffort:
|
||||
if not model.supported_reasoning_efforts:
|
||||
return ReasoningEffort.medium
|
||||
raise RuntimeError(f"{model.model} did not advertise supported reasoning efforts")
|
||||
|
||||
best = max(
|
||||
model.supported_reasoning_efforts,
|
||||
key=lambda option: REASONING_RANK.get(option.reasoning_effort.value, -1),
|
||||
key=lambda option: REASONING_RANK[option.reasoning_effort.value],
|
||||
)
|
||||
return ReasoningEffort(best.reasoning_effort.value)
|
||||
|
||||
@@ -95,18 +87,16 @@ with Codex(config=runtime_config()) as codex:
|
||||
)
|
||||
|
||||
first = thread.turn(
|
||||
TextInput("Give one short sentence about reliable production releases."),
|
||||
"Give one short sentence about reliable production releases.",
|
||||
model=selected_model.model,
|
||||
effort=selected_effort,
|
||||
).run()
|
||||
persisted = thread.read(include_turns=True)
|
||||
first_turn = find_turn_by_id(persisted.thread.turns, first.id)
|
||||
|
||||
print("agent.message:", assistant_text_from_turn(first_turn))
|
||||
print("items:", 0 if first_turn is None else len(first_turn.items or []))
|
||||
print("agent.message:", first.final_response)
|
||||
print("items:", len(first.items))
|
||||
|
||||
second = thread.turn(
|
||||
TextInput("Return JSON for a safe feature-flag rollout plan."),
|
||||
"Return JSON for a safe feature-flag rollout plan.",
|
||||
cwd=str(Path.cwd()),
|
||||
effort=selected_effort,
|
||||
model=selected_model.model,
|
||||
@@ -115,8 +105,6 @@ with Codex(config=runtime_config()) as codex:
|
||||
sandbox_policy=SANDBOX_POLICY,
|
||||
summary=ReasoningSummary.model_validate("concise"),
|
||||
).run()
|
||||
persisted = thread.read(include_turns=True)
|
||||
second_turn = find_turn_by_id(persisted.thread.turns, second.id)
|
||||
|
||||
print("agent.message.params:", assistant_text_from_turn(second_turn))
|
||||
print("items.params:", 0 if second_turn is None else len(second_turn.items or []))
|
||||
print("agent.message.params:", second.final_response)
|
||||
print("items.params:", len(second.items))
|
||||
|
||||
@@ -5,17 +5,13 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import (
|
||||
assistant_text_from_turn,
|
||||
ensure_local_sdk_src,
|
||||
runtime_config,
|
||||
)
|
||||
from _bootstrap import ensure_local_sdk_src, runtime_config
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
import asyncio
|
||||
|
||||
from openai_codex import AsyncCodex, TextInput
|
||||
from openai_codex import AsyncCodex
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
@@ -23,59 +19,49 @@ async def main() -> None:
|
||||
thread = await codex.thread_start(
|
||||
model="gpt-5.4", config={"model_reasoning_effort": "high"}
|
||||
)
|
||||
steer_turn = await thread.turn(
|
||||
TextInput("Count from 1 to 40 with commas, then one summary sentence.")
|
||||
)
|
||||
steer_result = "sent"
|
||||
try:
|
||||
_ = await steer_turn.steer(TextInput("Keep it brief and stop after 10 numbers."))
|
||||
except Exception as exc:
|
||||
steer_result = f"skipped {type(exc).__name__}"
|
||||
steer_turn = await thread.turn("Count from 1 to 40 with commas, then one summary sentence.")
|
||||
steer_result = await steer_turn.steer("Keep it brief and stop after 10 numbers.")
|
||||
|
||||
steer_event_count = 0
|
||||
steer_completed_status = "unknown"
|
||||
steer_completed_turn = None
|
||||
steer_completed_status = None
|
||||
steer_deltas = []
|
||||
async for event in steer_turn.stream():
|
||||
steer_event_count += 1
|
||||
if event.method == "item/agentMessage/delta":
|
||||
steer_deltas.append(event.payload.delta)
|
||||
continue
|
||||
if event.method == "turn/completed":
|
||||
steer_completed_turn = event.payload.turn
|
||||
steer_completed_status = getattr(
|
||||
event.payload.turn.status, "value", str(event.payload.turn.status)
|
||||
)
|
||||
steer_completed_status = event.payload.turn.status.value
|
||||
|
||||
steer_preview = (
|
||||
assistant_text_from_turn(steer_completed_turn).strip() or "[no assistant text]"
|
||||
)
|
||||
if steer_completed_status is None:
|
||||
raise RuntimeError("stream ended without turn/completed")
|
||||
steer_preview = "".join(steer_deltas).strip()
|
||||
|
||||
interrupt_turn = await thread.turn(
|
||||
TextInput("Count from 1 to 200 with commas, then one summary sentence.")
|
||||
"Count from 1 to 200 with commas, then one summary sentence."
|
||||
)
|
||||
interrupt_result = "sent"
|
||||
try:
|
||||
_ = await interrupt_turn.interrupt()
|
||||
except Exception as exc:
|
||||
interrupt_result = f"skipped {type(exc).__name__}"
|
||||
interrupt_result = await interrupt_turn.interrupt()
|
||||
|
||||
interrupt_event_count = 0
|
||||
interrupt_completed_status = "unknown"
|
||||
interrupt_completed_turn = None
|
||||
interrupt_completed_status = None
|
||||
interrupt_deltas = []
|
||||
async for event in interrupt_turn.stream():
|
||||
interrupt_event_count += 1
|
||||
if event.method == "item/agentMessage/delta":
|
||||
interrupt_deltas.append(event.payload.delta)
|
||||
continue
|
||||
if event.method == "turn/completed":
|
||||
interrupt_completed_turn = event.payload.turn
|
||||
interrupt_completed_status = getattr(
|
||||
event.payload.turn.status, "value", str(event.payload.turn.status)
|
||||
)
|
||||
interrupt_completed_status = event.payload.turn.status.value
|
||||
|
||||
interrupt_preview = (
|
||||
assistant_text_from_turn(interrupt_completed_turn).strip() or "[no assistant text]"
|
||||
)
|
||||
if interrupt_completed_status is None:
|
||||
raise RuntimeError("stream ended without turn/completed")
|
||||
interrupt_preview = "".join(interrupt_deltas).strip()
|
||||
|
||||
print("steer.result:", steer_result)
|
||||
print("steer.result:", steer_result.model_dump(mode="json", by_alias=True))
|
||||
print("steer.final.status:", steer_completed_status)
|
||||
print("steer.events.count:", steer_event_count)
|
||||
print("steer.assistant.preview:", steer_preview)
|
||||
print("interrupt.result:", interrupt_result)
|
||||
print("interrupt.result:", interrupt_result.model_dump(mode="json", by_alias=True))
|
||||
print("interrupt.final.status:", interrupt_completed_status)
|
||||
print("interrupt.events.count:", interrupt_event_count)
|
||||
print("interrupt.assistant.preview:", interrupt_preview)
|
||||
|
||||
@@ -5,69 +5,55 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_EXAMPLES_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_EXAMPLES_ROOT))
|
||||
|
||||
from _bootstrap import (
|
||||
assistant_text_from_turn,
|
||||
ensure_local_sdk_src,
|
||||
runtime_config,
|
||||
)
|
||||
from _bootstrap import ensure_local_sdk_src, runtime_config
|
||||
|
||||
ensure_local_sdk_src()
|
||||
|
||||
from openai_codex import Codex, TextInput
|
||||
from openai_codex import Codex
|
||||
|
||||
with Codex(config=runtime_config()) as codex:
|
||||
thread = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"})
|
||||
steer_turn = thread.turn(
|
||||
TextInput("Count from 1 to 40 with commas, then one summary sentence.")
|
||||
)
|
||||
steer_result = "sent"
|
||||
try:
|
||||
_ = steer_turn.steer(TextInput("Keep it brief and stop after 10 numbers."))
|
||||
except Exception as exc:
|
||||
steer_result = f"skipped {type(exc).__name__}"
|
||||
steer_turn = thread.turn("Count from 1 to 40 with commas, then one summary sentence.")
|
||||
steer_result = steer_turn.steer("Keep it brief and stop after 10 numbers.")
|
||||
|
||||
steer_event_count = 0
|
||||
steer_completed_status = "unknown"
|
||||
steer_completed_turn = None
|
||||
steer_completed_status = None
|
||||
steer_deltas = []
|
||||
for event in steer_turn.stream():
|
||||
steer_event_count += 1
|
||||
if event.method == "item/agentMessage/delta":
|
||||
steer_deltas.append(event.payload.delta)
|
||||
continue
|
||||
if event.method == "turn/completed":
|
||||
steer_completed_turn = event.payload.turn
|
||||
steer_completed_status = getattr(
|
||||
event.payload.turn.status, "value", str(event.payload.turn.status)
|
||||
)
|
||||
steer_completed_status = event.payload.turn.status.value
|
||||
|
||||
steer_preview = assistant_text_from_turn(steer_completed_turn).strip() or "[no assistant text]"
|
||||
if steer_completed_status is None:
|
||||
raise RuntimeError("stream ended without turn/completed")
|
||||
steer_preview = "".join(steer_deltas).strip()
|
||||
|
||||
interrupt_turn = thread.turn(
|
||||
TextInput("Count from 1 to 200 with commas, then one summary sentence.")
|
||||
)
|
||||
interrupt_result = "sent"
|
||||
try:
|
||||
_ = interrupt_turn.interrupt()
|
||||
except Exception as exc:
|
||||
interrupt_result = f"skipped {type(exc).__name__}"
|
||||
interrupt_turn = thread.turn("Count from 1 to 200 with commas, then one summary sentence.")
|
||||
interrupt_result = interrupt_turn.interrupt()
|
||||
|
||||
interrupt_event_count = 0
|
||||
interrupt_completed_status = "unknown"
|
||||
interrupt_completed_turn = None
|
||||
interrupt_completed_status = None
|
||||
interrupt_deltas = []
|
||||
for event in interrupt_turn.stream():
|
||||
interrupt_event_count += 1
|
||||
if event.method == "item/agentMessage/delta":
|
||||
interrupt_deltas.append(event.payload.delta)
|
||||
continue
|
||||
if event.method == "turn/completed":
|
||||
interrupt_completed_turn = event.payload.turn
|
||||
interrupt_completed_status = getattr(
|
||||
event.payload.turn.status, "value", str(event.payload.turn.status)
|
||||
)
|
||||
interrupt_completed_status = event.payload.turn.status.value
|
||||
|
||||
interrupt_preview = (
|
||||
assistant_text_from_turn(interrupt_completed_turn).strip() or "[no assistant text]"
|
||||
)
|
||||
if interrupt_completed_status is None:
|
||||
raise RuntimeError("stream ended without turn/completed")
|
||||
interrupt_preview = "".join(interrupt_deltas).strip()
|
||||
|
||||
print("steer.result:", steer_result)
|
||||
print("steer.result:", steer_result.model_dump(mode="json", by_alias=True))
|
||||
print("steer.final.status:", steer_completed_status)
|
||||
print("steer.events.count:", steer_event_count)
|
||||
print("steer.assistant.preview:", steer_preview)
|
||||
print("interrupt.result:", interrupt_result)
|
||||
print("interrupt.result:", interrupt_result.model_dump(mode="json", by_alias=True))
|
||||
print("interrupt.final.status:", interrupt_completed_status)
|
||||
print("interrupt.events.count:", interrupt_event_count)
|
||||
print("interrupt.assistant.preview:", interrupt_preview)
|
||||
|
||||
@@ -8,6 +8,9 @@ Each example folder contains runnable versions:
|
||||
All examples intentionally use only public SDK exports from `openai_codex`
|
||||
and `openai_codex.types`.
|
||||
|
||||
Examples use plain strings for text-only turns and typed input objects for
|
||||
multimodal or structured input lists.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python `>=3.10`
|
||||
@@ -81,6 +84,6 @@ python examples/01_quickstart_constructor/async.py
|
||||
- `13_model_select_and_turn_params/`
|
||||
- list models, pick highest model + highest supported reasoning effort, run turns, print message and usage
|
||||
- `14_turn_controls/`
|
||||
- separate best-effort `steer()` and `interrupt()` demos with concise summaries
|
||||
- separate `steer()` and `interrupt()` demos with concise summaries
|
||||
- `15_login_and_account/`
|
||||
- browser-login handle lifecycle, cancellation, and account inspection
|
||||
|
||||
@@ -6,7 +6,7 @@ import sys
|
||||
import tempfile
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Iterator
|
||||
from typing import Any, Iterator
|
||||
|
||||
_SDK_PYTHON_DIR = Path(__file__).resolve().parents[1]
|
||||
_SDK_PYTHON_STR = str(_SDK_PYTHON_DIR)
|
||||
@@ -103,53 +103,5 @@ def temporary_sample_image_path() -> Iterator[Path]:
|
||||
yield image_path
|
||||
|
||||
|
||||
def server_label(metadata: object) -> str:
|
||||
server = getattr(metadata, "serverInfo", None)
|
||||
server_name = ((getattr(server, "name", None) or "") if server is not None else "").strip()
|
||||
server_version = (
|
||||
(getattr(server, "version", None) or "") if server is not None else ""
|
||||
).strip()
|
||||
if server_name and server_version:
|
||||
return f"{server_name} {server_version}"
|
||||
|
||||
user_agent = (
|
||||
(getattr(metadata, "userAgent", None) or "") if metadata is not None else ""
|
||||
).strip()
|
||||
return user_agent or "unknown"
|
||||
|
||||
|
||||
def find_turn_by_id(turns: Iterable[object] | None, turn_id: str) -> object | None:
|
||||
for turn in turns or []:
|
||||
if getattr(turn, "id", None) == turn_id:
|
||||
return turn
|
||||
return None
|
||||
|
||||
|
||||
def assistant_text_from_turn(turn: object | None) -> str:
|
||||
if turn is None:
|
||||
return ""
|
||||
|
||||
chunks: list[str] = []
|
||||
for item in getattr(turn, "items", []) or []:
|
||||
raw_item = item.model_dump(mode="json") if hasattr(item, "model_dump") else item
|
||||
if not isinstance(raw_item, dict):
|
||||
continue
|
||||
|
||||
item_type = raw_item.get("type")
|
||||
if item_type == "agentMessage":
|
||||
text = raw_item.get("text")
|
||||
if isinstance(text, str) and text:
|
||||
chunks.append(text)
|
||||
continue
|
||||
|
||||
if item_type != "message" or raw_item.get("role") != "assistant":
|
||||
continue
|
||||
|
||||
for content in raw_item.get("content") or []:
|
||||
if not isinstance(content, dict) or content.get("type") != "output_text":
|
||||
continue
|
||||
text = content.get("text")
|
||||
if isinstance(text, str) and text:
|
||||
chunks.append(text)
|
||||
|
||||
return "".join(chunks)
|
||||
def server_label(metadata: Any) -> str:
|
||||
return f"{metadata.serverInfo.name} {metadata.serverInfo.version}"
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "1b6614a5",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
@@ -25,23 +26,10 @@
|
||||
" f'Notebook requires Python 3.10+; current interpreter is {sys.version.split()[0]}.'\n",
|
||||
" )\n",
|
||||
"\n",
|
||||
"try:\n",
|
||||
" _ = os.getcwd()\n",
|
||||
"except FileNotFoundError:\n",
|
||||
" os.chdir(str(Path.home()))\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def _is_sdk_python_dir(path: Path) -> bool:\n",
|
||||
" return (path / 'pyproject.toml').exists() and (path / 'src' / 'openai_codex').exists()\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def _iter_home_fallback_candidates(home: Path):\n",
|
||||
" # bounded depth scan under home to support launching notebooks from unrelated cwd values\n",
|
||||
" patterns = ('sdk/python', '*/sdk/python', '*/*/sdk/python', '*/*/*/sdk/python')\n",
|
||||
" for pattern in patterns:\n",
|
||||
" yield from home.glob(pattern)\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def _find_sdk_python_dir(start: Path) -> Path | None:\n",
|
||||
" checked = set()\n",
|
||||
"\n",
|
||||
@@ -70,21 +58,6 @@
|
||||
" if found is not None:\n",
|
||||
" return found\n",
|
||||
"\n",
|
||||
" for entry in sys.path:\n",
|
||||
" if not entry:\n",
|
||||
" continue\n",
|
||||
" entry_path = Path(entry).expanduser()\n",
|
||||
" for candidate in (entry_path, entry_path / 'sdk' / 'python'):\n",
|
||||
" found = _consider(candidate)\n",
|
||||
" if found is not None:\n",
|
||||
" return found\n",
|
||||
"\n",
|
||||
" home = Path.home()\n",
|
||||
" for candidate in _iter_home_fallback_candidates(home):\n",
|
||||
" found = _consider(candidate)\n",
|
||||
" if found is not None:\n",
|
||||
" return found\n",
|
||||
"\n",
|
||||
" return None\n",
|
||||
"\n",
|
||||
"\n",
|
||||
@@ -125,11 +98,12 @@
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "137a6d64",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Cell 2: imports (public only)\n",
|
||||
"from _bootstrap import assistant_text_from_turn, find_turn_by_id, server_label\n",
|
||||
"from _bootstrap import server_label\n",
|
||||
"from openai_codex import (\n",
|
||||
" AsyncCodex,\n",
|
||||
" Codex,\n",
|
||||
@@ -143,6 +117,7 @@
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "5fae892d",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
@@ -150,67 +125,59 @@
|
||||
"with Codex() as codex:\n",
|
||||
" # Open this URL and call `wait()` without canceling when completing login for real.\n",
|
||||
" login = codex.login_chatgpt()\n",
|
||||
" canceled = login.cancel()\n",
|
||||
" print('Please complete login at:', login.auth_url)\n",
|
||||
" completed = login.wait()\n",
|
||||
" account = codex.account()\n",
|
||||
"\n",
|
||||
" print('login.id:', login.login_id)\n",
|
||||
" print('login.auth_url:', login.auth_url)\n",
|
||||
" print('login.cancel.status:', canceled.status)\n",
|
||||
" print('login.completed.success:', completed.success)\n",
|
||||
" print('account.requires_openai_auth:', account.requires_openai_auth)\n"
|
||||
" print('account:', account.email)\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "ebdc04d9",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Cell 3: simple sync conversation\n",
|
||||
"with Codex() as codex:\n",
|
||||
" thread = codex.thread_start(model='gpt-5.4', config={'model_reasoning_effort': 'high'})\n",
|
||||
" turn = thread.turn(TextInput('Explain gradient descent in 3 bullets.'))\n",
|
||||
" result = turn.run()\n",
|
||||
" persisted = thread.read(include_turns=True)\n",
|
||||
" persisted_turn = find_turn_by_id(persisted.thread.turns, result.id)\n",
|
||||
"\n",
|
||||
" print('server:', server_label(codex.metadata))\n",
|
||||
" print('status:', result.status)\n",
|
||||
" print(assistant_text_from_turn(persisted_turn))\n"
|
||||
" result = thread.run('Explain gradient descent in 3 bullets.')\n",
|
||||
" print(result.final_response)\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "bb4abb96",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Cell 4: multi-turn continuity in same thread\n",
|
||||
"with Codex() as codex:\n",
|
||||
" thread = codex.thread_start(model='gpt-5.4', config={'model_reasoning_effort': 'high'})\n",
|
||||
"\n",
|
||||
" first = thread.turn(TextInput('Give a short summary of transformers.')).run()\n",
|
||||
" second = thread.turn(TextInput('Now explain that to a high-school student.')).run()\n",
|
||||
" persisted = thread.read(include_turns=True)\n",
|
||||
" second_turn = find_turn_by_id(persisted.thread.turns, second.id)\n",
|
||||
"\n",
|
||||
" first = thread.turn('Give a short summary of transformers.').run()\n",
|
||||
" second = thread.turn('Now explain that to a high-school student.').run()\n",
|
||||
" print('first status:', first.status)\n",
|
||||
" print('second status:', second.status)\n",
|
||||
" print('second text:', assistant_text_from_turn(second_turn))\n"
|
||||
" print('second text:', second.final_response)\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "8b0c80fd",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Cell 5: full thread lifecycle and branching (sync)\n",
|
||||
"with Codex() as codex:\n",
|
||||
" thread = codex.thread_start(model='gpt-5.4', config={'model_reasoning_effort': 'high'})\n",
|
||||
" first = thread.turn(TextInput('One sentence about structured planning.')).run()\n",
|
||||
" second = thread.turn(TextInput('Now restate it for a junior engineer.')).run()\n",
|
||||
" first = thread.turn('One sentence about structured planning.').run()\n",
|
||||
" second = thread.turn('Now restate it for a junior engineer.').run()\n",
|
||||
"\n",
|
||||
" reopened = codex.thread_resume(thread.id)\n",
|
||||
" listing_active = codex.thread_list(limit=20, archived=False)\n",
|
||||
@@ -221,46 +188,33 @@
|
||||
" listing_archived = codex.thread_list(limit=20, archived=True)\n",
|
||||
" unarchived = codex.thread_unarchive(reopened.id)\n",
|
||||
"\n",
|
||||
" resumed_info = 'n/a'\n",
|
||||
" try:\n",
|
||||
" resumed = codex.thread_resume(\n",
|
||||
" unarchived.id,\n",
|
||||
" model='gpt-5.4',\n",
|
||||
" config={'model_reasoning_effort': 'high'},\n",
|
||||
" )\n",
|
||||
" resumed_result = resumed.turn(TextInput('Continue in one short sentence.')).run()\n",
|
||||
" resumed_info = f'{resumed_result.id} {resumed_result.status}'\n",
|
||||
" except Exception as e:\n",
|
||||
" resumed_info = f'skipped({type(e).__name__})'\n",
|
||||
" resumed = codex.thread_resume(\n",
|
||||
" unarchived.id,\n",
|
||||
" model='gpt-5.4',\n",
|
||||
" config={'model_reasoning_effort': 'high'},\n",
|
||||
" )\n",
|
||||
" resumed_result = resumed.turn('Continue in one short sentence.').run()\n",
|
||||
"\n",
|
||||
" forked_info = 'n/a'\n",
|
||||
" try:\n",
|
||||
" forked = codex.thread_fork(unarchived.id, model='gpt-5.4')\n",
|
||||
" forked_result = forked.turn(TextInput('Take a different angle in one short sentence.')).run()\n",
|
||||
" forked_info = f'{forked_result.id} {forked_result.status}'\n",
|
||||
" except Exception as e:\n",
|
||||
" forked_info = f'skipped({type(e).__name__})'\n",
|
||||
" forked = codex.thread_fork(unarchived.id, model='gpt-5.4')\n",
|
||||
" forked_result = forked.turn('Take a different angle in one short sentence.').run()\n",
|
||||
"\n",
|
||||
" compact_info = 'sent'\n",
|
||||
" try:\n",
|
||||
" _ = unarchived.compact()\n",
|
||||
" except Exception as e:\n",
|
||||
" compact_info = f'skipped({type(e).__name__})'\n",
|
||||
" compact_result = unarchived.compact()\n",
|
||||
"\n",
|
||||
" print('Lifecycle OK:', thread.id)\n",
|
||||
" print('first:', first.id, first.status)\n",
|
||||
" print('second:', second.id, second.status)\n",
|
||||
" print('read.turns:', len(reading.thread.turns or []))\n",
|
||||
" print('read.turns:', len(reading.thread.turns))\n",
|
||||
" print('list.active:', len(listing_active.data))\n",
|
||||
" print('list.archived:', len(listing_archived.data))\n",
|
||||
" print('resumed:', resumed_info)\n",
|
||||
" print('forked:', forked_info)\n",
|
||||
" print('compact:', compact_info)\n"
|
||||
" print('resumed:', resumed_result.id, resumed_result.status)\n",
|
||||
" print('forked:', forked_result.id, forked_result.status)\n",
|
||||
" print('compact:', compact_result.model_dump(mode='json', by_alias=True))\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "310db8c0",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
@@ -289,7 +243,7 @@
|
||||
"with Codex() as codex:\n",
|
||||
" thread = codex.thread_start(model='gpt-5.4', config={'model_reasoning_effort': 'high'})\n",
|
||||
" turn = thread.turn(\n",
|
||||
" TextInput('Propose a safe production feature-flag rollout. Return JSON matching the schema.'),\n",
|
||||
" 'Propose a safe production feature-flag rollout. Return JSON matching the schema.',\n",
|
||||
" cwd=str(Path.cwd()),\n",
|
||||
" effort=ReasoningEffort.medium,\n",
|
||||
" model='gpt-5.4',\n",
|
||||
@@ -299,16 +253,14 @@
|
||||
" summary=summary,\n",
|
||||
" )\n",
|
||||
" result = turn.run()\n",
|
||||
" persisted = thread.read(include_turns=True)\n",
|
||||
" persisted_turn = find_turn_by_id(persisted.thread.turns, result.id)\n",
|
||||
"\n",
|
||||
" print('status:', result.status)\n",
|
||||
" print(assistant_text_from_turn(persisted_turn))\n"
|
||||
" print(result.final_response)\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "7a33c97d",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
@@ -332,17 +284,20 @@
|
||||
"\n",
|
||||
"\n",
|
||||
"def pick_highest_model(models):\n",
|
||||
" visible = [m for m in models if not m.hidden] or models\n",
|
||||
" visible = [m for m in models if not m.hidden]\n",
|
||||
" if not visible:\n",
|
||||
" raise RuntimeError('models response did not include visible models')\n",
|
||||
" known_names = {m.id for m in visible} | {m.model for m in visible}\n",
|
||||
" top_candidates = [m for m in visible if not (m.upgrade and m.upgrade in known_names)]\n",
|
||||
" pool = top_candidates or visible\n",
|
||||
" return max(pool, key=lambda m: (m.model, m.id))\n",
|
||||
" if not top_candidates:\n",
|
||||
" raise RuntimeError('models response did not include top-level visible models')\n",
|
||||
" return max(top_candidates, key=lambda m: (m.model, m.id))\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def pick_highest_turn_effort(model) -> ReasoningEffort:\n",
|
||||
" if not model.supported_reasoning_efforts:\n",
|
||||
" return ReasoningEffort.medium\n",
|
||||
" best = max(model.supported_reasoning_efforts, key=lambda opt: reasoning_rank.get(opt.reasoning_effort.value, -1))\n",
|
||||
" raise RuntimeError(f'{model.model} did not advertise supported reasoning efforts')\n",
|
||||
" best = max(model.supported_reasoning_efforts, key=lambda opt: reasoning_rank[opt.reasoning_effort.value])\n",
|
||||
" return ReasoningEffort(best.reasoning_effort.value)\n",
|
||||
"\n",
|
||||
"\n",
|
||||
@@ -368,17 +323,15 @@
|
||||
" thread = codex.thread_start(model=selected_model.model, config={'model_reasoning_effort': selected_effort.value})\n",
|
||||
"\n",
|
||||
" first = thread.turn(\n",
|
||||
" TextInput('Give one short sentence about reliable production releases.'),\n",
|
||||
" 'Give one short sentence about reliable production releases.',\n",
|
||||
" model=selected_model.model,\n",
|
||||
" effort=selected_effort,\n",
|
||||
" ).run()\n",
|
||||
" persisted = thread.read(include_turns=True)\n",
|
||||
" first_turn = find_turn_by_id(persisted.thread.turns, first.id)\n",
|
||||
" print('agent.message:', assistant_text_from_turn(first_turn))\n",
|
||||
" print('items:', 0 if first_turn is None else len(first_turn.items or []))\n",
|
||||
" print('agent.message:', first.final_response)\n",
|
||||
" print('items:', len(first.items))\n",
|
||||
"\n",
|
||||
" second = thread.turn(\n",
|
||||
" TextInput('Return JSON for a safe feature-flag rollout plan.'),\n",
|
||||
" 'Return JSON for a safe feature-flag rollout plan.',\n",
|
||||
" cwd=str(Path.cwd()),\n",
|
||||
" effort=selected_effort,\n",
|
||||
" model=selected_model.model,\n",
|
||||
@@ -387,15 +340,14 @@
|
||||
" sandbox_policy=sandbox_policy,\n",
|
||||
" summary=ReasoningSummary.model_validate('concise'),\n",
|
||||
" ).run()\n",
|
||||
" persisted = thread.read(include_turns=True)\n",
|
||||
" second_turn = find_turn_by_id(persisted.thread.turns, second.id)\n",
|
||||
" print('agent.message.params:', assistant_text_from_turn(second_turn))\n",
|
||||
" print('items.params:', 0 if second_turn is None else len(second_turn.items or []))\n"
|
||||
" print('agent.message.params:', second.final_response)\n",
|
||||
" print('items.params:', len(second.items))\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "e9aef26a",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
@@ -408,16 +360,14 @@
|
||||
" TextInput('What do you see in this image? 3 bullets.'),\n",
|
||||
" ImageInput(remote_image_url),\n",
|
||||
" ]).run()\n",
|
||||
" persisted = thread.read(include_turns=True)\n",
|
||||
" persisted_turn = find_turn_by_id(persisted.thread.turns, result.id)\n",
|
||||
"\n",
|
||||
" print('status:', result.status)\n",
|
||||
" print(assistant_text_from_turn(persisted_turn))\n"
|
||||
" print(result.final_response)\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "a0cecc6c",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
@@ -429,16 +379,14 @@
|
||||
" TextInput('Describe the colors and layout in this generated local image in 2 bullets.'),\n",
|
||||
" LocalImageInput(str(local_image_path.resolve())),\n",
|
||||
" ]).run()\n",
|
||||
" persisted = thread.read(include_turns=True)\n",
|
||||
" persisted_turn = find_turn_by_id(persisted.thread.turns, result.id)\n",
|
||||
"\n",
|
||||
" print('status:', result.status)\n",
|
||||
" print(assistant_text_from_turn(persisted_turn))\n"
|
||||
" print(result.final_response)\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "91afa2b8",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
@@ -447,21 +395,19 @@
|
||||
" thread = codex.thread_start(model='gpt-5.4', config={'model_reasoning_effort': 'high'})\n",
|
||||
"\n",
|
||||
" result = retry_on_overload(\n",
|
||||
" lambda: thread.turn(TextInput('List 5 failure modes in distributed systems.')).run(),\n",
|
||||
" lambda: thread.turn('List 5 failure modes in distributed systems.').run(),\n",
|
||||
" max_attempts=3,\n",
|
||||
" initial_delay_s=0.25,\n",
|
||||
" max_delay_s=2.0,\n",
|
||||
" )\n",
|
||||
" persisted = thread.read(include_turns=True)\n",
|
||||
" persisted_turn = find_turn_by_id(persisted.thread.turns, result.id)\n",
|
||||
"\n",
|
||||
" print('status:', result.status)\n",
|
||||
" print(assistant_text_from_turn(persisted_turn))\n"
|
||||
" print(result.final_response)\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "103be934",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
@@ -472,8 +418,8 @@
|
||||
"async def async_lifecycle_demo():\n",
|
||||
" async with AsyncCodex() as codex:\n",
|
||||
" thread = await codex.thread_start(model='gpt-5.4', config={'model_reasoning_effort': 'high'})\n",
|
||||
" first = await (await thread.turn(TextInput('One sentence about structured planning.'))).run()\n",
|
||||
" second = await (await thread.turn(TextInput('Now restate it for a junior engineer.'))).run()\n",
|
||||
" first = await (await thread.turn('One sentence about structured planning.')).run()\n",
|
||||
" second = await (await thread.turn('Now restate it for a junior engineer.')).run()\n",
|
||||
"\n",
|
||||
" reopened = await codex.thread_resume(thread.id)\n",
|
||||
" listing_active = await codex.thread_list(limit=20, archived=False)\n",
|
||||
@@ -484,41 +430,27 @@
|
||||
" listing_archived = await codex.thread_list(limit=20, archived=True)\n",
|
||||
" unarchived = await codex.thread_unarchive(reopened.id)\n",
|
||||
"\n",
|
||||
" resumed_info = 'n/a'\n",
|
||||
" try:\n",
|
||||
" resumed = await codex.thread_resume(\n",
|
||||
" unarchived.id,\n",
|
||||
" model='gpt-5.4',\n",
|
||||
" config={'model_reasoning_effort': 'high'},\n",
|
||||
" )\n",
|
||||
" resumed_result = await (await resumed.turn(TextInput('Continue in one short sentence.'))).run()\n",
|
||||
" resumed_info = f'{resumed_result.id} {resumed_result.status}'\n",
|
||||
" except Exception as e:\n",
|
||||
" resumed_info = f'skipped({type(e).__name__})'\n",
|
||||
" resumed = await codex.thread_resume(\n",
|
||||
" unarchived.id,\n",
|
||||
" model='gpt-5.4',\n",
|
||||
" config={'model_reasoning_effort': 'high'},\n",
|
||||
" )\n",
|
||||
" resumed_result = await (await resumed.turn('Continue in one short sentence.')).run()\n",
|
||||
"\n",
|
||||
" forked_info = 'n/a'\n",
|
||||
" try:\n",
|
||||
" forked = await codex.thread_fork(unarchived.id, model='gpt-5.4')\n",
|
||||
" forked_result = await (await forked.turn(TextInput('Take a different angle in one short sentence.'))).run()\n",
|
||||
" forked_info = f'{forked_result.id} {forked_result.status}'\n",
|
||||
" except Exception as e:\n",
|
||||
" forked_info = f'skipped({type(e).__name__})'\n",
|
||||
" forked = await codex.thread_fork(unarchived.id, model='gpt-5.4')\n",
|
||||
" forked_result = await (await forked.turn('Take a different angle in one short sentence.')).run()\n",
|
||||
"\n",
|
||||
" compact_info = 'sent'\n",
|
||||
" try:\n",
|
||||
" _ = await unarchived.compact()\n",
|
||||
" except Exception as e:\n",
|
||||
" compact_info = f'skipped({type(e).__name__})'\n",
|
||||
" compact_result = await unarchived.compact()\n",
|
||||
"\n",
|
||||
" print('Lifecycle OK:', thread.id)\n",
|
||||
" print('first:', first.id, first.status)\n",
|
||||
" print('second:', second.id, second.status)\n",
|
||||
" print('read.turns:', len(reading.thread.turns or []))\n",
|
||||
" print('read.turns:', len(reading.thread.turns))\n",
|
||||
" print('list.active:', len(listing_active.data))\n",
|
||||
" print('list.archived:', len(listing_archived.data))\n",
|
||||
" print('resumed:', resumed_info)\n",
|
||||
" print('forked:', forked_info)\n",
|
||||
" print('compact:', compact_info)\n",
|
||||
" print('resumed:', resumed_result.id, resumed_result.status)\n",
|
||||
" print('forked:', forked_result.id, forked_result.status)\n",
|
||||
" print('compact:', compact_result.model_dump(mode='json', by_alias=True))\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"await async_lifecycle_demo()\n"
|
||||
@@ -527,58 +459,59 @@
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "365aa10c",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Cell 10: async turn controls (best effort steer + interrupt)\n",
|
||||
"# Cell 10: async turn controls (steer + interrupt)\n",
|
||||
"import asyncio\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"async def async_stream_demo():\n",
|
||||
" async with AsyncCodex() as codex:\n",
|
||||
" thread = await codex.thread_start(model='gpt-5.4', config={'model_reasoning_effort': 'high'})\n",
|
||||
" steer_turn = await thread.turn(TextInput('Count from 1 to 40 with commas, then one summary sentence.'))\n",
|
||||
" steer_turn = await thread.turn('Count from 1 to 40 with commas, then one summary sentence.')\n",
|
||||
"\n",
|
||||
" steer_result = 'sent'\n",
|
||||
" try:\n",
|
||||
" _ = await steer_turn.steer(TextInput('Keep it brief and stop after 10 numbers.'))\n",
|
||||
" except Exception as e:\n",
|
||||
" steer_result = f'skipped {type(e).__name__}'\n",
|
||||
" steer_result = await steer_turn.steer('Keep it brief and stop after 10 numbers.')\n",
|
||||
"\n",
|
||||
" steer_event_count = 0\n",
|
||||
" steer_completed_status = 'unknown'\n",
|
||||
" steer_completed_turn = None\n",
|
||||
" steer_completed_status = None\n",
|
||||
" steer_deltas = []\n",
|
||||
" async for event in steer_turn.stream():\n",
|
||||
" steer_event_count += 1\n",
|
||||
" if event.method == 'item/agentMessage/delta':\n",
|
||||
" steer_deltas.append(event.payload.delta)\n",
|
||||
" continue\n",
|
||||
" if event.method == 'turn/completed':\n",
|
||||
" steer_completed_turn = event.payload.turn\n",
|
||||
" steer_completed_status = getattr(event.payload.turn.status, 'value', str(event.payload.turn.status))\n",
|
||||
" steer_completed_status = event.payload.turn.status.value\n",
|
||||
"\n",
|
||||
" steer_preview = assistant_text_from_turn(steer_completed_turn).strip() or '[no assistant text]'\n",
|
||||
" if steer_completed_status is None:\n",
|
||||
" raise RuntimeError('stream ended without turn/completed')\n",
|
||||
" steer_preview = ''.join(steer_deltas).strip()\n",
|
||||
"\n",
|
||||
" interrupt_turn = await thread.turn(TextInput('Count from 1 to 200 with commas, then one summary sentence.'))\n",
|
||||
" interrupt_result = 'sent'\n",
|
||||
" try:\n",
|
||||
" _ = await interrupt_turn.interrupt()\n",
|
||||
" except Exception as e:\n",
|
||||
" interrupt_result = f'skipped {type(e).__name__}'\n",
|
||||
" interrupt_turn = await thread.turn('Count from 1 to 200 with commas, then one summary sentence.')\n",
|
||||
" interrupt_result = await interrupt_turn.interrupt()\n",
|
||||
"\n",
|
||||
" interrupt_event_count = 0\n",
|
||||
" interrupt_completed_status = 'unknown'\n",
|
||||
" interrupt_completed_turn = None\n",
|
||||
" interrupt_completed_status = None\n",
|
||||
" interrupt_deltas = []\n",
|
||||
" async for event in interrupt_turn.stream():\n",
|
||||
" interrupt_event_count += 1\n",
|
||||
" if event.method == 'item/agentMessage/delta':\n",
|
||||
" interrupt_deltas.append(event.payload.delta)\n",
|
||||
" continue\n",
|
||||
" if event.method == 'turn/completed':\n",
|
||||
" interrupt_completed_turn = event.payload.turn\n",
|
||||
" interrupt_completed_status = getattr(event.payload.turn.status, 'value', str(event.payload.turn.status))\n",
|
||||
" interrupt_completed_status = event.payload.turn.status.value\n",
|
||||
"\n",
|
||||
" interrupt_preview = assistant_text_from_turn(interrupt_completed_turn).strip() or '[no assistant text]'\n",
|
||||
" if interrupt_completed_status is None:\n",
|
||||
" raise RuntimeError('stream ended without turn/completed')\n",
|
||||
" interrupt_preview = ''.join(interrupt_deltas).strip()\n",
|
||||
"\n",
|
||||
" print('steer.result:', steer_result)\n",
|
||||
" print('steer.result:', steer_result.model_dump(mode='json', by_alias=True))\n",
|
||||
" print('steer.final.status:', steer_completed_status)\n",
|
||||
" print('steer.events.count:', steer_event_count)\n",
|
||||
" print('steer.assistant.preview:', steer_preview)\n",
|
||||
" print('interrupt.result:', interrupt_result)\n",
|
||||
" print('interrupt.result:', interrupt_result.model_dump(mode='json', by_alias=True))\n",
|
||||
" print('interrupt.final.status:', interrupt_completed_status)\n",
|
||||
" print('interrupt.events.count:', interrupt_event_count)\n",
|
||||
" print('interrupt.assistant.preview:', interrupt_preview)\n",
|
||||
@@ -590,13 +523,21 @@
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3",
|
||||
"display_name": ".venv",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"codemirror_mode": {
|
||||
"name": "ipython",
|
||||
"version": 3
|
||||
},
|
||||
"file_extension": ".py",
|
||||
"mimetype": "text/x-python",
|
||||
"name": "python",
|
||||
"version": "3.10+"
|
||||
"nbconvert_exporter": "python",
|
||||
"pygments_lexer": "ipython3",
|
||||
"version": "3.14.3"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
|
||||
@@ -1040,12 +1040,12 @@ def _render_thread_block(
|
||||
lines = [
|
||||
" def turn(",
|
||||
" self,",
|
||||
" input: Input,",
|
||||
" input: RunInput,",
|
||||
" *,",
|
||||
*_approval_mode_override_signature_lines(),
|
||||
*_kw_signature_lines(turn_fields),
|
||||
" ) -> TurnHandle:",
|
||||
" wire_input = _to_wire_input(input)",
|
||||
" wire_input = _to_wire_input(_normalize_run_input(input))",
|
||||
_approval_mode_assignment_line("_approval_mode_override_settings"),
|
||||
" params = TurnStartParams(",
|
||||
" thread_id=self.id,",
|
||||
@@ -1065,13 +1065,13 @@ def _render_async_thread_block(
|
||||
lines = [
|
||||
" async def turn(",
|
||||
" self,",
|
||||
" input: Input,",
|
||||
" input: RunInput,",
|
||||
" *,",
|
||||
*_approval_mode_override_signature_lines(),
|
||||
*_kw_signature_lines(turn_fields),
|
||||
" ) -> AsyncTurnHandle:",
|
||||
" await self._codex._ensure_initialized()",
|
||||
" wire_input = _to_wire_input(input)",
|
||||
" wire_input = _to_wire_input(_normalize_run_input(input))",
|
||||
_approval_mode_assignment_line("_approval_mode_override_settings"),
|
||||
" params = TurnStartParams(",
|
||||
" thread_id=self.id,",
|
||||
|
||||
@@ -14,11 +14,12 @@ from .api import (
|
||||
InputItem,
|
||||
LocalImageInput,
|
||||
MentionInput,
|
||||
RunResult,
|
||||
RunInput,
|
||||
SkillInput,
|
||||
TextInput,
|
||||
Thread,
|
||||
TurnHandle,
|
||||
TurnResult,
|
||||
)
|
||||
from .client import AppServerConfig
|
||||
from .errors import (
|
||||
@@ -51,9 +52,10 @@ __all__ = [
|
||||
"AsyncThread",
|
||||
"TurnHandle",
|
||||
"AsyncTurnHandle",
|
||||
"RunResult",
|
||||
"TurnResult",
|
||||
"Input",
|
||||
"InputItem",
|
||||
"RunInput",
|
||||
"TextInput",
|
||||
"ImageInput",
|
||||
"LocalImageInput",
|
||||
|
||||
@@ -12,13 +12,20 @@ from .generated.v2_all import (
|
||||
ThreadTokenUsageUpdatedNotification,
|
||||
Turn as AppServerTurn,
|
||||
TurnCompletedNotification,
|
||||
TurnError,
|
||||
TurnStatus,
|
||||
)
|
||||
from .models import Notification
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RunResult:
|
||||
class TurnResult:
|
||||
id: str
|
||||
status: TurnStatus
|
||||
error: TurnError | None
|
||||
started_at: int | None
|
||||
completed_at: int | None
|
||||
duration_ms: int | None
|
||||
final_response: str | None
|
||||
items: list[ThreadItem]
|
||||
usage: ThreadTokenUsage | None
|
||||
@@ -56,7 +63,7 @@ def _raise_for_failed_turn(turn: AppServerTurn) -> None:
|
||||
raise RuntimeError(f"turn failed with status {turn.status.value}")
|
||||
|
||||
|
||||
def _collect_run_result(stream: Iterator[Notification], *, turn_id: str) -> RunResult:
|
||||
def _collect_turn_result(stream: Iterator[Notification], *, turn_id: str) -> TurnResult:
|
||||
completed: TurnCompletedNotification | None = None
|
||||
items: list[ThreadItem] = []
|
||||
usage: ThreadTokenUsage | None = None
|
||||
@@ -76,16 +83,23 @@ def _collect_run_result(stream: Iterator[Notification], *, turn_id: str) -> RunR
|
||||
raise RuntimeError("turn completed event not received")
|
||||
|
||||
_raise_for_failed_turn(completed.turn)
|
||||
return RunResult(
|
||||
turn = completed.turn
|
||||
return TurnResult(
|
||||
id=turn.id,
|
||||
status=turn.status,
|
||||
error=turn.error,
|
||||
started_at=turn.started_at,
|
||||
completed_at=turn.completed_at,
|
||||
duration_ms=turn.duration_ms,
|
||||
final_response=_final_assistant_response_from_items(items),
|
||||
items=items,
|
||||
usage=usage,
|
||||
)
|
||||
|
||||
|
||||
async def _collect_async_run_result(
|
||||
async def _collect_async_turn_result(
|
||||
stream: AsyncIterator[Notification], *, turn_id: str
|
||||
) -> RunResult:
|
||||
) -> TurnResult:
|
||||
completed: TurnCompletedNotification | None = None
|
||||
items: list[ThreadItem] = []
|
||||
usage: ThreadTokenUsage | None = None
|
||||
@@ -105,7 +119,14 @@ async def _collect_async_run_result(
|
||||
raise RuntimeError("turn completed event not received")
|
||||
|
||||
_raise_for_failed_turn(completed.turn)
|
||||
return RunResult(
|
||||
turn = completed.turn
|
||||
return TurnResult(
|
||||
id=turn.id,
|
||||
status=turn.status,
|
||||
error=turn.error,
|
||||
started_at=turn.started_at,
|
||||
completed_at=turn.completed_at,
|
||||
duration_ms=turn.duration_ms,
|
||||
final_response=_final_assistant_response_from_items(items),
|
||||
items=items,
|
||||
usage=usage,
|
||||
|
||||
@@ -12,7 +12,7 @@ from ._approval_mode import (
|
||||
from ._initialize_metadata import validate_initialize_metadata
|
||||
from ._inputs import (
|
||||
ImageInput as ImageInput,
|
||||
Input,
|
||||
Input as Input,
|
||||
InputItem as InputItem,
|
||||
LocalImageInput as LocalImageInput,
|
||||
MentionInput as MentionInput,
|
||||
@@ -33,9 +33,9 @@ from ._login import (
|
||||
start_device_code_login,
|
||||
)
|
||||
from ._run import (
|
||||
RunResult,
|
||||
_collect_async_run_result,
|
||||
_collect_run_result,
|
||||
TurnResult,
|
||||
_collect_async_turn_result,
|
||||
_collect_turn_result,
|
||||
)
|
||||
from .async_client import AsyncAppServerClient
|
||||
from .client import AppServerClient, AppServerConfig
|
||||
@@ -65,7 +65,6 @@ from .generated.v2_all import (
|
||||
ThreadSourceKind,
|
||||
ThreadStartParams,
|
||||
ThreadStartSource,
|
||||
Turn as AppServerTurn,
|
||||
TurnCompletedNotification,
|
||||
TurnInterruptResponse,
|
||||
TurnStartParams,
|
||||
@@ -533,9 +532,9 @@ class Thread:
|
||||
sandbox_policy: SandboxPolicy | None = None,
|
||||
service_tier: str | None = None,
|
||||
summary: ReasoningSummary | None = None,
|
||||
) -> RunResult:
|
||||
) -> TurnResult:
|
||||
turn = self.turn(
|
||||
_normalize_run_input(input),
|
||||
input,
|
||||
approval_mode=approval_mode,
|
||||
cwd=cwd,
|
||||
effort=effort,
|
||||
@@ -548,14 +547,14 @@ class Thread:
|
||||
)
|
||||
stream = turn.stream()
|
||||
try:
|
||||
return _collect_run_result(stream, turn_id=turn.id)
|
||||
return _collect_turn_result(stream, turn_id=turn.id)
|
||||
finally:
|
||||
stream.close()
|
||||
|
||||
# BEGIN GENERATED: Thread.flat_methods
|
||||
def turn(
|
||||
self,
|
||||
input: Input,
|
||||
input: RunInput,
|
||||
*,
|
||||
approval_mode: ApprovalMode | None = None,
|
||||
cwd: str | None = None,
|
||||
@@ -567,7 +566,7 @@ class Thread:
|
||||
service_tier: str | None = None,
|
||||
summary: ReasoningSummary | None = None,
|
||||
) -> TurnHandle:
|
||||
wire_input = _to_wire_input(input)
|
||||
wire_input = _to_wire_input(_normalize_run_input(input))
|
||||
approval_policy, approvals_reviewer = _approval_mode_override_settings(approval_mode)
|
||||
params = TurnStartParams(
|
||||
thread_id=self.id,
|
||||
@@ -616,9 +615,9 @@ class AsyncThread:
|
||||
sandbox_policy: SandboxPolicy | None = None,
|
||||
service_tier: str | None = None,
|
||||
summary: ReasoningSummary | None = None,
|
||||
) -> RunResult:
|
||||
) -> TurnResult:
|
||||
turn = await self.turn(
|
||||
_normalize_run_input(input),
|
||||
input,
|
||||
approval_mode=approval_mode,
|
||||
cwd=cwd,
|
||||
effort=effort,
|
||||
@@ -631,14 +630,14 @@ class AsyncThread:
|
||||
)
|
||||
stream = turn.stream()
|
||||
try:
|
||||
return await _collect_async_run_result(stream, turn_id=turn.id)
|
||||
return await _collect_async_turn_result(stream, turn_id=turn.id)
|
||||
finally:
|
||||
await stream.aclose()
|
||||
|
||||
# BEGIN GENERATED: AsyncThread.flat_methods
|
||||
async def turn(
|
||||
self,
|
||||
input: Input,
|
||||
input: RunInput,
|
||||
*,
|
||||
approval_mode: ApprovalMode | None = None,
|
||||
cwd: str | None = None,
|
||||
@@ -651,7 +650,7 @@ class AsyncThread:
|
||||
summary: ReasoningSummary | None = None,
|
||||
) -> AsyncTurnHandle:
|
||||
await self._codex._ensure_initialized()
|
||||
wire_input = _to_wire_input(input)
|
||||
wire_input = _to_wire_input(_normalize_run_input(input))
|
||||
approval_policy, approvals_reviewer = _approval_mode_override_settings(approval_mode)
|
||||
params = TurnStartParams(
|
||||
thread_id=self.id,
|
||||
@@ -695,8 +694,12 @@ class TurnHandle:
|
||||
thread_id: str
|
||||
id: str
|
||||
|
||||
def steer(self, input: Input) -> TurnSteerResponse:
|
||||
return self._client.turn_steer(self.thread_id, self.id, _to_wire_input(input))
|
||||
def steer(self, input: RunInput) -> TurnSteerResponse:
|
||||
return self._client.turn_steer(
|
||||
self.thread_id,
|
||||
self.id,
|
||||
_to_wire_input(_normalize_run_input(input)),
|
||||
)
|
||||
|
||||
def interrupt(self) -> TurnInterruptResponse:
|
||||
return self._client.turn_interrupt(self.thread_id, self.id)
|
||||
@@ -717,21 +720,13 @@ class TurnHandle:
|
||||
finally:
|
||||
self._client.unregister_turn_notifications(self.id)
|
||||
|
||||
def run(self) -> AppServerTurn:
|
||||
completed: TurnCompletedNotification | None = None
|
||||
def run(self) -> TurnResult:
|
||||
stream = self.stream()
|
||||
try:
|
||||
for event in stream:
|
||||
payload = event.payload
|
||||
if isinstance(payload, TurnCompletedNotification) and payload.turn.id == self.id:
|
||||
completed = payload
|
||||
return _collect_turn_result(stream, turn_id=self.id)
|
||||
finally:
|
||||
stream.close()
|
||||
|
||||
if completed is None:
|
||||
raise RuntimeError("turn completed event not received")
|
||||
return completed.turn
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AsyncTurnHandle:
|
||||
@@ -739,12 +734,12 @@ class AsyncTurnHandle:
|
||||
thread_id: str
|
||||
id: str
|
||||
|
||||
async def steer(self, input: Input) -> TurnSteerResponse:
|
||||
async def steer(self, input: RunInput) -> TurnSteerResponse:
|
||||
await self._codex._ensure_initialized()
|
||||
return await self._codex._client.turn_steer(
|
||||
self.thread_id,
|
||||
self.id,
|
||||
_to_wire_input(input),
|
||||
_to_wire_input(_normalize_run_input(input)),
|
||||
)
|
||||
|
||||
async def interrupt(self) -> TurnInterruptResponse:
|
||||
@@ -768,17 +763,9 @@ class AsyncTurnHandle:
|
||||
finally:
|
||||
self._codex._client.unregister_turn_notifications(self.id)
|
||||
|
||||
async def run(self) -> AppServerTurn:
|
||||
completed: TurnCompletedNotification | None = None
|
||||
async def run(self) -> TurnResult:
|
||||
stream = self.stream()
|
||||
try:
|
||||
async for event in stream:
|
||||
payload = event.payload
|
||||
if isinstance(payload, TurnCompletedNotification) and payload.turn.id == self.id:
|
||||
completed = payload
|
||||
return await _collect_async_turn_result(stream, turn_id=self.id)
|
||||
finally:
|
||||
await stream.aclose()
|
||||
|
||||
if completed is None:
|
||||
raise RuntimeError("turn completed event not received")
|
||||
return completed.turn
|
||||
|
||||
@@ -33,6 +33,7 @@ from .generated.v2_all import (
|
||||
ThreadTokenUsageUpdatedNotification,
|
||||
Turn,
|
||||
TurnCompletedNotification,
|
||||
TurnError,
|
||||
TurnInterruptResponse,
|
||||
TurnStatus,
|
||||
TurnSteerResponse,
|
||||
@@ -73,6 +74,7 @@ __all__ = [
|
||||
"ThreadTokenUsageUpdatedNotification",
|
||||
"Turn",
|
||||
"TurnCompletedNotification",
|
||||
"TurnError",
|
||||
"TurnInterruptResponse",
|
||||
"TurnStatus",
|
||||
"TurnSteerResponse",
|
||||
|
||||
@@ -111,7 +111,7 @@ def agent_message_texts(events: list[Notification]) -> list[str]:
|
||||
|
||||
|
||||
def agent_message_texts_from_items(items: Iterable[Any]) -> list[str]:
|
||||
"""Extract agent-message text from completed run result items."""
|
||||
"""Extract agent-message text from completed turn result items."""
|
||||
texts: list[str] = []
|
||||
for item in items:
|
||||
root = item.root
|
||||
|
||||
@@ -157,7 +157,7 @@ def test_async_lifecycle_methods_round_trip(tmp_path) -> None:
|
||||
|
||||
async with AsyncCodex(config=harness.app_server_config()) as codex:
|
||||
thread = await codex.thread_start()
|
||||
run_result = await thread.run("materialize async thread")
|
||||
turn_result = await thread.run("materialize async thread")
|
||||
await thread.set_name("async lifecycle")
|
||||
named = await thread.read()
|
||||
resumed = await codex.thread_resume(thread.id)
|
||||
@@ -166,14 +166,14 @@ def test_async_lifecycle_methods_round_trip(tmp_path) -> None:
|
||||
unarchived = await codex.thread_unarchive(thread.id)
|
||||
|
||||
assert {
|
||||
"run_final_response": run_result.final_response,
|
||||
"turn_final_response": turn_result.final_response,
|
||||
"named_thread": named.thread.name,
|
||||
"resumed_id": resumed.id,
|
||||
"forked_is_distinct": forked.id != thread.id,
|
||||
"archive_response": archive_response.model_dump(by_alias=True, mode="json"),
|
||||
"unarchived_id": unarchived.id,
|
||||
} == {
|
||||
"run_final_response": "async materialized",
|
||||
"turn_final_response": "async materialized",
|
||||
"named_thread": "async lifecycle",
|
||||
"resumed_id": thread.id,
|
||||
"forked_is_distinct": True,
|
||||
@@ -253,19 +253,19 @@ def test_compact_rpc_hits_mock_responses(tmp_path) -> None:
|
||||
|
||||
with Codex(config=harness.app_server_config()) as codex:
|
||||
thread = codex.thread_start()
|
||||
run_result = thread.run("create history")
|
||||
turn_result = thread.run("create history")
|
||||
compact_response = thread.compact()
|
||||
requests = harness.responses.wait_for_requests(2)
|
||||
|
||||
assert {
|
||||
"run_final_response": run_result.final_response,
|
||||
"turn_final_response": turn_result.final_response,
|
||||
"compact_response": compact_response.model_dump(
|
||||
by_alias=True,
|
||||
mode="json",
|
||||
),
|
||||
"request_kinds": [request_kind(request.path) for request in requests],
|
||||
} == {
|
||||
"run_final_response": "history",
|
||||
"turn_final_response": "history",
|
||||
"compact_response": {},
|
||||
"request_kinds": ["responses", "responses"],
|
||||
}
|
||||
|
||||
@@ -145,8 +145,8 @@ def test_async_thread_run_uses_mock_responses(
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_sync_run_result_uses_last_unknown_phase_message(tmp_path) -> None:
|
||||
"""RunResult should use the last unknown-phase agent message as final text."""
|
||||
def test_sync_turn_result_uses_last_unknown_phase_message(tmp_path) -> None:
|
||||
"""TurnResult should use the last unknown-phase agent message as final text."""
|
||||
with AppServerHarness(tmp_path) as harness:
|
||||
harness.responses.enqueue_sse(
|
||||
sse(
|
||||
@@ -171,8 +171,8 @@ def test_sync_run_result_uses_last_unknown_phase_message(tmp_path) -> None:
|
||||
}
|
||||
|
||||
|
||||
def test_sync_run_result_preserves_empty_last_message(tmp_path) -> None:
|
||||
"""RunResult should preserve an empty final agent message instead of skipping it."""
|
||||
def test_sync_turn_result_preserves_empty_last_message(tmp_path) -> None:
|
||||
"""TurnResult should preserve an empty final agent message instead of skipping it."""
|
||||
with AppServerHarness(tmp_path) as harness:
|
||||
harness.responses.enqueue_sse(
|
||||
sse(
|
||||
@@ -197,8 +197,8 @@ def test_sync_run_result_preserves_empty_last_message(tmp_path) -> None:
|
||||
}
|
||||
|
||||
|
||||
def test_sync_run_result_does_not_promote_commentary_only_to_final(tmp_path) -> None:
|
||||
"""RunResult final_response should stay unset when app-server marks only commentary."""
|
||||
def test_sync_turn_result_does_not_promote_commentary_only_to_final(tmp_path) -> None:
|
||||
"""TurnResult final_response should stay unset when app-server marks only commentary."""
|
||||
with AppServerHarness(tmp_path) as harness:
|
||||
harness.responses.enqueue_sse(
|
||||
sse(
|
||||
@@ -226,8 +226,8 @@ def test_sync_run_result_does_not_promote_commentary_only_to_final(tmp_path) ->
|
||||
}
|
||||
|
||||
|
||||
def test_async_run_result_uses_last_unknown_phase_message(tmp_path) -> None:
|
||||
"""Async RunResult should use the last unknown-phase agent message."""
|
||||
def test_async_turn_result_uses_last_unknown_phase_message(tmp_path) -> None:
|
||||
"""Async TurnResult should use the last unknown-phase agent message."""
|
||||
|
||||
async def scenario() -> None:
|
||||
"""Run one async result-mapping case against a pinned app-server."""
|
||||
@@ -263,10 +263,10 @@ def test_async_run_result_uses_last_unknown_phase_message(tmp_path) -> None:
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_async_run_result_does_not_promote_commentary_only_to_final(
|
||||
def test_async_turn_result_does_not_promote_commentary_only_to_final(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
"""Async RunResult final_response should stay unset for commentary-only output."""
|
||||
"""Async TurnResult final_response should stay unset for commentary-only output."""
|
||||
|
||||
async def scenario() -> None:
|
||||
"""Run one async commentary mapping case against a pinned app-server."""
|
||||
@@ -318,7 +318,7 @@ def test_thread_run_raises_when_real_app_server_reports_failed_turn(tmp_path) ->
|
||||
|
||||
|
||||
def test_final_answer_phase_survives_real_app_server_mapping(tmp_path) -> None:
|
||||
"""RunResult should use the final-answer item emitted by app-server."""
|
||||
"""TurnResult should use the final-answer item emitted by app-server."""
|
||||
with AppServerHarness(tmp_path) as harness:
|
||||
harness.responses.enqueue_sse(
|
||||
sse(
|
||||
|
||||
@@ -5,12 +5,13 @@ import asyncio
|
||||
from app_server_harness import AppServerHarness
|
||||
from app_server_helpers import (
|
||||
agent_message_texts,
|
||||
agent_message_texts_from_items,
|
||||
next_async_delta,
|
||||
next_sync_delta,
|
||||
streaming_response,
|
||||
)
|
||||
|
||||
from openai_codex import AsyncCodex, Codex, TextInput
|
||||
from openai_codex import AsyncCodex, Codex
|
||||
from openai_codex.generated.v2_all import (
|
||||
AgentMessageDeltaNotification,
|
||||
TurnCompletedNotification,
|
||||
@@ -25,8 +26,9 @@ def test_sync_stream_routes_text_deltas_and_completion(tmp_path) -> None:
|
||||
|
||||
with Codex(config=harness.app_server_config()) as codex:
|
||||
thread = codex.thread_start()
|
||||
stream = thread.turn(TextInput("stream please")).stream()
|
||||
stream = thread.turn("stream please").stream()
|
||||
events = list(stream)
|
||||
request = harness.responses.single_request()
|
||||
|
||||
assert {
|
||||
"deltas": [
|
||||
@@ -35,6 +37,7 @@ def test_sync_stream_routes_text_deltas_and_completion(tmp_path) -> None:
|
||||
if isinstance(event.payload, AgentMessageDeltaNotification)
|
||||
],
|
||||
"agent_messages": agent_message_texts(events),
|
||||
"request_user_texts": request.message_input_texts("user")[-1:],
|
||||
"completed_statuses": [
|
||||
event.payload.turn.status
|
||||
for event in events
|
||||
@@ -43,28 +46,31 @@ def test_sync_stream_routes_text_deltas_and_completion(tmp_path) -> None:
|
||||
} == {
|
||||
"deltas": ["he", "llo"],
|
||||
"agent_messages": ["hello"],
|
||||
"request_user_texts": ["stream please"],
|
||||
"completed_statuses": [TurnStatus.completed],
|
||||
}
|
||||
|
||||
|
||||
def test_turn_run_returns_completed_turn(tmp_path) -> None:
|
||||
"""TurnHandle.run should wait for the app-server completion notification."""
|
||||
"""TurnHandle.run should collect output and completion metadata."""
|
||||
with AppServerHarness(tmp_path) as harness:
|
||||
harness.responses.enqueue_assistant_message("turn complete", response_id="turn-run-1")
|
||||
|
||||
with Codex(config=harness.app_server_config()) as codex:
|
||||
thread = codex.thread_start()
|
||||
turn = thread.turn(TextInput("complete this turn"))
|
||||
turn = thread.turn("complete this turn")
|
||||
completed = turn.run()
|
||||
|
||||
assert {
|
||||
"turn_id": completed.id,
|
||||
"status": completed.status,
|
||||
"items": completed.items,
|
||||
"agent_messages": agent_message_texts_from_items(completed.items),
|
||||
"final_response": completed.final_response,
|
||||
} == {
|
||||
"turn_id": turn.id,
|
||||
"status": TurnStatus.completed,
|
||||
"items": [],
|
||||
"agent_messages": ["turn complete"],
|
||||
"final_response": "turn complete",
|
||||
}
|
||||
|
||||
|
||||
@@ -80,8 +86,9 @@ def test_async_stream_routes_text_deltas_and_completion(tmp_path) -> None:
|
||||
|
||||
async with AsyncCodex(config=harness.app_server_config()) as codex:
|
||||
thread = await codex.thread_start()
|
||||
turn = await thread.turn(TextInput("async stream please"))
|
||||
turn = await thread.turn("async stream please")
|
||||
events = [event async for event in turn.stream()]
|
||||
request = harness.responses.single_request()
|
||||
|
||||
assert {
|
||||
"deltas": [
|
||||
@@ -90,6 +97,7 @@ def test_async_stream_routes_text_deltas_and_completion(tmp_path) -> None:
|
||||
if isinstance(event.payload, AgentMessageDeltaNotification)
|
||||
],
|
||||
"agent_messages": agent_message_texts(events),
|
||||
"request_user_texts": request.message_input_texts("user")[-1:],
|
||||
"completed_statuses": [
|
||||
event.payload.turn.status
|
||||
for event in events
|
||||
@@ -98,6 +106,7 @@ def test_async_stream_routes_text_deltas_and_completion(tmp_path) -> None:
|
||||
} == {
|
||||
"deltas": ["as", "ync"],
|
||||
"agent_messages": ["async"],
|
||||
"request_user_texts": ["async stream please"],
|
||||
"completed_statuses": [TurnStatus.completed],
|
||||
}
|
||||
|
||||
@@ -175,8 +184,8 @@ def test_interleaved_sync_turn_streams_route_by_turn_id(tmp_path) -> None:
|
||||
with Codex(config=harness.app_server_config()) as codex:
|
||||
first_thread = codex.thread_start()
|
||||
second_thread = codex.thread_start()
|
||||
first_turn = first_thread.turn(TextInput("first"))
|
||||
second_turn = second_thread.turn(TextInput("second"))
|
||||
first_turn = first_thread.turn("first")
|
||||
second_turn = second_thread.turn("second")
|
||||
|
||||
first_stream = first_turn.stream()
|
||||
second_stream = second_turn.stream()
|
||||
@@ -228,8 +237,8 @@ def test_interleaved_async_turn_streams_route_by_turn_id(tmp_path) -> None:
|
||||
async with AsyncCodex(config=harness.app_server_config()) as codex:
|
||||
first_thread = await codex.thread_start()
|
||||
second_thread = await codex.thread_start()
|
||||
first_turn = await first_thread.turn(TextInput("async first"))
|
||||
second_turn = await second_thread.turn(TextInput("async second"))
|
||||
first_turn = await first_thread.turn("async first")
|
||||
second_turn = await second_thread.turn("async second")
|
||||
|
||||
first_stream = first_turn.stream()
|
||||
second_stream = second_turn.stream()
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
from app_server_harness import AppServerHarness
|
||||
from app_server_helpers import agent_message_texts, streaming_response
|
||||
|
||||
from openai_codex import Codex, TextInput
|
||||
from openai_codex import Codex
|
||||
from openai_codex.generated.v2_all import TurnStatus
|
||||
|
||||
|
||||
@@ -21,9 +21,9 @@ def test_turn_steer_adds_follow_up_input(tmp_path) -> None:
|
||||
|
||||
with Codex(config=harness.app_server_config()) as codex:
|
||||
thread = codex.thread_start()
|
||||
turn = thread.turn(TextInput("Start a steerable turn."))
|
||||
turn = thread.turn("Start a steerable turn.")
|
||||
harness.responses.wait_for_requests(1)
|
||||
steer = turn.steer(TextInput("Use this steering input."))
|
||||
steer = turn.steer("Use this steering input.")
|
||||
events = list(turn.stream())
|
||||
requests = harness.responses.wait_for_requests(2)
|
||||
|
||||
@@ -61,7 +61,7 @@ def test_turn_interrupt_stops_active_turn_and_follow_up_runs(tmp_path) -> None:
|
||||
|
||||
with Codex(config=harness.app_server_config()) as codex:
|
||||
thread = codex.thread_start()
|
||||
interrupted_turn = thread.turn(TextInput("Start a long turn."))
|
||||
interrupted_turn = thread.turn("Start a long turn.")
|
||||
harness.responses.wait_for_requests(1)
|
||||
interrupt_response = interrupted_turn.interrupt()
|
||||
completed = interrupted_turn.run()
|
||||
|
||||
@@ -14,9 +14,11 @@ from openai_codex import (
|
||||
AppServerConfig,
|
||||
AsyncCodex,
|
||||
AsyncThread,
|
||||
AsyncTurnHandle,
|
||||
Codex,
|
||||
RunResult,
|
||||
Thread,
|
||||
TurnHandle,
|
||||
TurnResult,
|
||||
)
|
||||
from openai_codex._initialize_metadata import validate_initialize_metadata
|
||||
from openai_codex.types import InitializeResponse
|
||||
@@ -35,9 +37,10 @@ EXPECTED_ROOT_EXPORTS = [
|
||||
"AsyncThread",
|
||||
"TurnHandle",
|
||||
"AsyncTurnHandle",
|
||||
"RunResult",
|
||||
"TurnResult",
|
||||
"Input",
|
||||
"InputItem",
|
||||
"RunInput",
|
||||
"TextInput",
|
||||
"ImageInput",
|
||||
"LocalImageInput",
|
||||
@@ -92,6 +95,7 @@ EXPECTED_TYPES_EXPORTS = [
|
||||
"ThreadTokenUsageUpdatedNotification",
|
||||
"Turn",
|
||||
"TurnCompletedNotification",
|
||||
"TurnError",
|
||||
"TurnInterruptResponse",
|
||||
"TurnStatus",
|
||||
"TurnSteerResponse",
|
||||
@@ -128,9 +132,55 @@ def test_root_exports_app_server_config() -> None:
|
||||
assert AppServerConfig.__name__ == "AppServerConfig"
|
||||
|
||||
|
||||
def test_root_exports_run_result() -> None:
|
||||
"""The root package should expose the common-case run result wrapper."""
|
||||
assert RunResult.__name__ == "RunResult"
|
||||
def test_root_exports_turn_result() -> None:
|
||||
"""The root package should expose the collected turn result wrapper."""
|
||||
assert {
|
||||
"name": TurnResult.__name__,
|
||||
"fields": list(TurnResult.__dataclass_fields__),
|
||||
} == {
|
||||
"name": "TurnResult",
|
||||
"fields": [
|
||||
"id",
|
||||
"status",
|
||||
"error",
|
||||
"started_at",
|
||||
"completed_at",
|
||||
"duration_ms",
|
||||
"final_response",
|
||||
"items",
|
||||
"usage",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_turn_run_methods_return_turn_result() -> None:
|
||||
"""Both convenience and handle-based run APIs return the same result shape."""
|
||||
funcs = [
|
||||
Thread.run,
|
||||
TurnHandle.run,
|
||||
AsyncThread.run,
|
||||
AsyncTurnHandle.run,
|
||||
]
|
||||
|
||||
assert {fn: inspect.signature(fn).return_annotation for fn in funcs} == dict.fromkeys(
|
||||
funcs, "TurnResult"
|
||||
)
|
||||
|
||||
|
||||
def test_turn_input_methods_accept_string_shortcut() -> None:
|
||||
"""Every public turn-input method should accept strings and typed inputs."""
|
||||
funcs = [
|
||||
Thread.run,
|
||||
Thread.turn,
|
||||
AsyncThread.run,
|
||||
AsyncThread.turn,
|
||||
TurnHandle.steer,
|
||||
AsyncTurnHandle.steer,
|
||||
]
|
||||
|
||||
assert {fn: inspect.signature(fn).parameters["input"].annotation for fn in funcs} == (
|
||||
dict.fromkeys(funcs, "RunInput")
|
||||
)
|
||||
|
||||
|
||||
def test_root_exports_approval_mode() -> None:
|
||||
|
||||
@@ -135,7 +135,7 @@ def _run_python(
|
||||
)
|
||||
|
||||
|
||||
def _runtime_compatibility_hint(
|
||||
def _runtime_schema_hint(
|
||||
runtime_env: PreparedRuntimeEnv,
|
||||
*,
|
||||
stdout: str,
|
||||
@@ -144,7 +144,7 @@ def _runtime_compatibility_hint(
|
||||
combined = f"{stdout}\n{stderr}"
|
||||
if "ThreadStartResponse" in combined and "approvalsReviewer" in combined:
|
||||
return (
|
||||
"\nCompatibility hint:\n"
|
||||
"\nSchema hint:\n"
|
||||
f"Pinned runtime {runtime_env.runtime_version} returned a thread/start payload "
|
||||
"that is older than the current SDK schema and is missing "
|
||||
"`approvalsReviewer`. Bump `sdk/python/_runtime_setup.py` to a matching "
|
||||
@@ -165,7 +165,7 @@ def _run_json_python(
|
||||
"Python snippet failed.\n"
|
||||
f"STDOUT:\n{result.stdout}\n"
|
||||
f"STDERR:\n{result.stderr}"
|
||||
f"{_runtime_compatibility_hint(runtime_env, stdout=result.stdout, stderr=result.stderr)}"
|
||||
f"{_runtime_schema_hint(runtime_env, stdout=result.stdout, stderr=result.stderr)}"
|
||||
)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
@@ -234,25 +234,20 @@ def test_real_thread_and_turn_start_smoke(runtime_env: PreparedRuntimeEnv) -> No
|
||||
textwrap.dedent(
|
||||
"""
|
||||
import json
|
||||
from openai_codex import Codex, TextInput
|
||||
from openai_codex import Codex
|
||||
|
||||
with Codex() as codex:
|
||||
thread = codex.thread_start(
|
||||
model="gpt-5.4",
|
||||
config={"model_reasoning_effort": "high"},
|
||||
)
|
||||
result = thread.turn(TextInput("hello")).run()
|
||||
persisted = thread.read(include_turns=True)
|
||||
persisted_turn = next(
|
||||
(turn for turn in persisted.thread.turns or [] if turn.id == result.id),
|
||||
None,
|
||||
)
|
||||
result = thread.turn("hello").run()
|
||||
print(json.dumps({
|
||||
"thread_id": thread.id,
|
||||
"turn_id": result.id,
|
||||
"status": result.status.value,
|
||||
"items_count": len(result.items or []),
|
||||
"persisted_items_count": 0 if persisted_turn is None else len(persisted_turn.items or []),
|
||||
"items_count": len(result.items),
|
||||
"final_response_is_text": isinstance(result.final_response, str) and bool(result.final_response.strip()),
|
||||
}))
|
||||
"""
|
||||
),
|
||||
@@ -261,8 +256,8 @@ def test_real_thread_and_turn_start_smoke(runtime_env: PreparedRuntimeEnv) -> No
|
||||
assert isinstance(data["thread_id"], str) and data["thread_id"].strip()
|
||||
assert isinstance(data["turn_id"], str) and data["turn_id"].strip()
|
||||
assert data["status"] == "completed"
|
||||
assert isinstance(data["items_count"], int)
|
||||
assert isinstance(data["persisted_items_count"], int)
|
||||
assert data["items_count"] > 0
|
||||
assert data["final_response_is_text"] is True
|
||||
|
||||
|
||||
def test_real_thread_run_convenience_smoke(runtime_env: PreparedRuntimeEnv) -> None:
|
||||
@@ -336,7 +331,7 @@ def test_real_async_thread_turn_usage_and_ids_smoke(
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
from openai_codex import AsyncCodex, TextInput
|
||||
from openai_codex import AsyncCodex
|
||||
|
||||
async def main():
|
||||
async with AsyncCodex() as codex:
|
||||
@@ -344,18 +339,13 @@ def test_real_async_thread_turn_usage_and_ids_smoke(
|
||||
model="gpt-5.4",
|
||||
config={"model_reasoning_effort": "high"},
|
||||
)
|
||||
result = await (await thread.turn(TextInput("say ok"))).run()
|
||||
persisted = await thread.read(include_turns=True)
|
||||
persisted_turn = next(
|
||||
(turn for turn in persisted.thread.turns or [] if turn.id == result.id),
|
||||
None,
|
||||
)
|
||||
result = await (await thread.turn("say ok")).run()
|
||||
print(json.dumps({
|
||||
"thread_id": thread.id,
|
||||
"turn_id": result.id,
|
||||
"status": result.status.value,
|
||||
"items_count": len(result.items or []),
|
||||
"persisted_items_count": 0 if persisted_turn is None else len(persisted_turn.items or []),
|
||||
"items_count": len(result.items),
|
||||
"final_response_is_text": isinstance(result.final_response, str) and bool(result.final_response.strip()),
|
||||
}))
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -366,8 +356,8 @@ def test_real_async_thread_turn_usage_and_ids_smoke(
|
||||
assert isinstance(data["thread_id"], str) and data["thread_id"].strip()
|
||||
assert isinstance(data["turn_id"], str) and data["turn_id"].strip()
|
||||
assert data["status"] == "completed"
|
||||
assert isinstance(data["items_count"], int)
|
||||
assert isinstance(data["persisted_items_count"], int)
|
||||
assert data["items_count"] > 0
|
||||
assert data["final_response_is_text"] is True
|
||||
|
||||
|
||||
def test_real_async_thread_run_convenience_smoke(
|
||||
@@ -468,14 +458,14 @@ def test_real_streaming_smoke_turn_completed(runtime_env: PreparedRuntimeEnv) ->
|
||||
textwrap.dedent(
|
||||
"""
|
||||
import json
|
||||
from openai_codex import Codex, TextInput
|
||||
from openai_codex import Codex
|
||||
|
||||
with Codex() as codex:
|
||||
thread = codex.thread_start(
|
||||
model="gpt-5.4",
|
||||
config={"model_reasoning_effort": "high"},
|
||||
)
|
||||
turn = thread.turn(TextInput("Reply with one short sentence."))
|
||||
turn = thread.turn("Reply with one short sentence.")
|
||||
saw_delta = False
|
||||
saw_completed = False
|
||||
for event in turn.stream():
|
||||
@@ -501,16 +491,16 @@ def test_real_turn_interrupt_smoke(runtime_env: PreparedRuntimeEnv) -> None:
|
||||
textwrap.dedent(
|
||||
"""
|
||||
import json
|
||||
from openai_codex import Codex, TextInput
|
||||
from openai_codex import Codex
|
||||
|
||||
with Codex() as codex:
|
||||
thread = codex.thread_start(
|
||||
model="gpt-5.4",
|
||||
config={"model_reasoning_effort": "high"},
|
||||
)
|
||||
turn = thread.turn(TextInput("Count from 1 to 200 with commas."))
|
||||
turn = thread.turn("Count from 1 to 200 with commas.")
|
||||
turn.interrupt()
|
||||
follow_up = thread.turn(TextInput("Say 'ok' only.")).run()
|
||||
follow_up = thread.turn("Say 'ok' only.").run()
|
||||
print(json.dumps({"status": follow_up.status.value}))
|
||||
"""
|
||||
),
|
||||
@@ -531,7 +521,7 @@ def test_real_examples_run_and_assert(
|
||||
f"Example failed: {folder}/{script}\n"
|
||||
f"STDOUT:\n{result.stdout}\n"
|
||||
f"STDERR:\n{result.stderr}"
|
||||
f"{_runtime_compatibility_hint(runtime_env, stdout=result.stdout, stderr=result.stderr)}"
|
||||
f"{_runtime_schema_hint(runtime_env, stdout=result.stdout, stderr=result.stderr)}"
|
||||
)
|
||||
|
||||
out = result.stdout
|
||||
@@ -541,7 +531,7 @@ def test_real_examples_run_and_assert(
|
||||
assert "Server: unknown" not in out
|
||||
elif folder == "02_turn_run":
|
||||
assert "thread_id:" in out and "turn_id:" in out and "status:" in out
|
||||
assert "persisted.items.count:" in out
|
||||
assert "items.count:" in out
|
||||
elif folder == "03_turn_stream_events":
|
||||
assert "stream.completed:" in out
|
||||
assert "assistant>" in out
|
||||
|
||||
Reference in New Issue
Block a user