mirror of
https://github.com/openai/codex.git
synced 2026-05-11 14:52:36 +00:00
Compare commits
1 Commits
bot/update
...
dev/spawn-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb1794622b |
12
codex-rs/Cargo.lock
generated
12
codex-rs/Cargo.lock
generated
@@ -3706,18 +3706,6 @@ dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-tool-api"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"codex-protocol",
|
||||
"codex-tools",
|
||||
"pretty_assertions",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-tools"
|
||||
version = "0.0.0"
|
||||
|
||||
@@ -108,7 +108,6 @@ members = [
|
||||
"test-binary-support",
|
||||
"thread-manager-sample",
|
||||
"thread-store",
|
||||
"tool-api",
|
||||
"uds",
|
||||
"codex-experimental-api-macros",
|
||||
"plugin",
|
||||
@@ -210,7 +209,6 @@ codex-stdio-to-uds = { path = "stdio-to-uds" }
|
||||
codex-terminal-detection = { path = "terminal-detection" }
|
||||
codex-test-binary-support = { path = "test-binary-support" }
|
||||
codex-thread-store = { path = "thread-store" }
|
||||
codex-tool-api = { path = "tool-api" }
|
||||
codex-tools = { path = "tools" }
|
||||
codex-tui = { path = "tui" }
|
||||
codex-uds = { path = "uds" }
|
||||
@@ -474,7 +472,6 @@ unwrap_used = "deny"
|
||||
[workspace.metadata.cargo-shear]
|
||||
ignored = [
|
||||
"codex-agent-graph-store",
|
||||
"codex-tool-api",
|
||||
"icu_provider",
|
||||
"openssl-sys",
|
||||
"codex-v8-poc",
|
||||
|
||||
@@ -5,7 +5,9 @@ use codex_extension_api::ExtensionRegistry;
|
||||
use codex_extension_api::ExtensionRegistryBuilder;
|
||||
|
||||
pub(crate) fn thread_extensions() -> Arc<ExtensionRegistry<Config>> {
|
||||
let mut builder = ExtensionRegistryBuilder::<Config>::new();
|
||||
codex_git_attribution::install(&mut builder);
|
||||
Arc::new(builder.build())
|
||||
Arc::new(
|
||||
ExtensionRegistryBuilder::<Config>::new()
|
||||
.with_extension(codex_git_attribution::extension())
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -101,6 +101,13 @@ impl ToolHandler for Handler {
|
||||
.await
|
||||
.map_err(FunctionCallError::RespondToModel)?;
|
||||
}
|
||||
apply_spawn_agent_service_tier(
|
||||
&session,
|
||||
&mut config,
|
||||
turn.config.service_tier.as_deref(),
|
||||
args.service_tier.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?;
|
||||
apply_spawn_agent_overrides(&mut config, child_depth);
|
||||
|
||||
@@ -203,6 +210,7 @@ struct SpawnAgentArgs {
|
||||
agent_type: Option<String>,
|
||||
model: Option<String>,
|
||||
reasoning_effort: Option<ReasoningEffort>,
|
||||
service_tier: Option<String>,
|
||||
#[serde(default)]
|
||||
fork_context: bool,
|
||||
}
|
||||
|
||||
@@ -336,6 +336,52 @@ pub(crate) async fn apply_requested_spawn_agent_model_overrides(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn apply_spawn_agent_service_tier(
|
||||
session: &Session,
|
||||
config: &mut Config,
|
||||
parent_service_tier: Option<&str>,
|
||||
requested_service_tier: Option<&str>,
|
||||
) -> Result<(), FunctionCallError> {
|
||||
let Some(candidate_service_tier) = requested_service_tier.or(parent_service_tier) else {
|
||||
config.service_tier = None;
|
||||
return Ok(());
|
||||
};
|
||||
let model = config.model.clone().ok_or_else(|| {
|
||||
FunctionCallError::RespondToModel(
|
||||
"spawn_agent could not resolve the child model for service tier validation".to_string(),
|
||||
)
|
||||
})?;
|
||||
let model_info = session
|
||||
.services
|
||||
.models_manager
|
||||
.get_model_info(model.as_str(), &config.to_models_manager_config())
|
||||
.await;
|
||||
|
||||
if model_info.supports_service_tier(candidate_service_tier) {
|
||||
config.service_tier = Some(candidate_service_tier.to_string());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if requested_service_tier.is_none() {
|
||||
config.service_tier = None;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let supported_service_tiers = if model_info.service_tiers.is_empty() {
|
||||
"none".to_string()
|
||||
} else {
|
||||
model_info
|
||||
.service_tiers
|
||||
.iter()
|
||||
.map(|tier| tier.id.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
};
|
||||
Err(FunctionCallError::RespondToModel(format!(
|
||||
"Service tier `{candidate_service_tier}` is not supported for model `{model}`. Supported service tiers: {supported_service_tiers}"
|
||||
)))
|
||||
}
|
||||
|
||||
fn find_spawn_agent_model_name(
|
||||
available_models: &[codex_protocol::openai_models::ModelPreset],
|
||||
requested_model: &str,
|
||||
|
||||
@@ -8,6 +8,7 @@ use std::collections::BTreeMap;
|
||||
|
||||
const SPAWN_AGENT_INHERITED_MODEL_GUIDANCE: &str = "Spawned agents inherit your current model by default. Omit `model` to use that preferred default; set `model` only when an explicit override is needed.";
|
||||
const SPAWN_AGENT_MODEL_OVERRIDE_DESCRIPTION: &str = "Optional model override for the new agent. Leave unset to inherit the same model as the parent, which is the preferred default. Only set this when the user explicitly asks for a different model or the task clearly requires one.";
|
||||
const SPAWN_AGENT_SERVICE_TIER_OVERRIDE_DESCRIPTION: &str = "Optional service tier override for the new agent. Leave unset to inherit the parent's active service tier when the spawned agent's effective model supports it; otherwise no service tier is used.";
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SpawnAgentToolOptions {
|
||||
@@ -545,6 +546,12 @@ fn spawn_agent_common_properties_v1(agent_type_description: &str) -> BTreeMap<St
|
||||
.to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"service_tier".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
SPAWN_AGENT_SERVICE_TIER_OVERRIDE_DESCRIPTION.to_string(),
|
||||
)),
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -578,6 +585,12 @@ fn spawn_agent_common_properties_v2(agent_type_description: &str) -> BTreeMap<St
|
||||
.to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"service_tier".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
SPAWN_AGENT_SERVICE_TIER_OVERRIDE_DESCRIPTION.to_string(),
|
||||
)),
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -585,6 +598,7 @@ fn hide_spawn_agent_metadata_options(properties: &mut BTreeMap<String, JsonSchem
|
||||
properties.remove("agent_type");
|
||||
properties.remove("model");
|
||||
properties.remove("reasoning_effort");
|
||||
properties.remove("service_tier");
|
||||
}
|
||||
|
||||
fn spawn_agent_tool_description(
|
||||
@@ -712,13 +726,24 @@ fn spawn_agent_models_description(models: &[ModelPreset]) -> String {
|
||||
.map(|preset| format!("{} ({})", preset.effort, preset.description))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let service_tiers = if model.service_tiers.is_empty() {
|
||||
"none".to_string()
|
||||
} else {
|
||||
model
|
||||
.service_tiers
|
||||
.iter()
|
||||
.map(|tier| format!("{} ({}: {})", tier.id, tier.name, tier.description))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
};
|
||||
format!(
|
||||
"- {} (`{}`): {} Default reasoning effort: {}. Supported reasoning efforts: {}.",
|
||||
"- {} (`{}`): {} Default reasoning effort: {}. Supported reasoning efforts: {}. Supported service tiers: {}.",
|
||||
model.display_name,
|
||||
model.model,
|
||||
model.description,
|
||||
model.default_reasoning_effort,
|
||||
efforts
|
||||
efforts,
|
||||
service_tiers
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::*;
|
||||
use codex_protocol::openai_models::ModelPreset;
|
||||
use codex_protocol::openai_models::ModelServiceTier;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use codex_protocol::openai_models::ReasoningEffortPreset;
|
||||
use codex_tools::JsonSchemaPrimitiveType;
|
||||
@@ -20,7 +21,11 @@ fn model_preset(id: &str, show_in_picker: bool) -> ModelPreset {
|
||||
}],
|
||||
supports_personality: false,
|
||||
additional_speed_tiers: Vec::new(),
|
||||
service_tiers: Vec::new(),
|
||||
service_tiers: vec![ModelServiceTier {
|
||||
id: "priority".to_string(),
|
||||
name: "Fast".to_string(),
|
||||
description: "1.5x speed, increased usage".to_string(),
|
||||
}],
|
||||
is_default: false,
|
||||
upgrade: None,
|
||||
show_in_picker,
|
||||
@@ -70,6 +75,10 @@ fn spawn_agent_tool_v2_requires_task_name_and_lists_visible_models() {
|
||||
.contains("Available model overrides (optional; inherited parent model is preferred):")
|
||||
);
|
||||
assert!(description.contains("visible display (`visible-model`)"));
|
||||
assert!(
|
||||
description
|
||||
.contains("Supported service tiers: priority (Fast: 1.5x speed, increased usage).")
|
||||
);
|
||||
assert!(!description.contains("hidden display (`hidden-model`)"));
|
||||
assert!(properties.contains_key("task_name"));
|
||||
assert!(properties.contains_key("message"));
|
||||
@@ -86,6 +95,12 @@ fn spawn_agent_tool_v2_requires_task_name_and_lists_visible_models() {
|
||||
.and_then(|schema| schema.description.as_deref()),
|
||||
Some(SPAWN_AGENT_MODEL_OVERRIDE_DESCRIPTION)
|
||||
);
|
||||
assert_eq!(
|
||||
properties
|
||||
.get("service_tier")
|
||||
.and_then(|schema| schema.description.as_deref()),
|
||||
Some(SPAWN_AGENT_SERVICE_TIER_OVERRIDE_DESCRIPTION)
|
||||
);
|
||||
assert_eq!(
|
||||
parameters.required.as_ref(),
|
||||
Some(&vec!["task_name".to_string(), "message".to_string()])
|
||||
@@ -127,6 +142,37 @@ fn spawn_agent_tool_v1_keeps_legacy_fork_context_field() {
|
||||
.and_then(|schema| schema.description.as_deref()),
|
||||
Some(SPAWN_AGENT_MODEL_OVERRIDE_DESCRIPTION)
|
||||
);
|
||||
assert_eq!(
|
||||
properties
|
||||
.get("service_tier")
|
||||
.and_then(|schema| schema.description.as_deref()),
|
||||
Some(SPAWN_AGENT_SERVICE_TIER_OVERRIDE_DESCRIPTION)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_agent_tool_hides_service_tier_with_spawn_metadata() {
|
||||
let tool = create_spawn_agent_tool_v2(SpawnAgentToolOptions {
|
||||
available_models: vec![model_preset("visible", /*show_in_picker*/ true)],
|
||||
agent_type_description: "role help".to_string(),
|
||||
hide_agent_type_model_reasoning: true,
|
||||
include_usage_hint: true,
|
||||
usage_hint_text: None,
|
||||
max_concurrent_threads_per_session: Some(4),
|
||||
});
|
||||
|
||||
let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = tool else {
|
||||
panic!("spawn_agent should be a function tool");
|
||||
};
|
||||
let properties = parameters
|
||||
.properties
|
||||
.as_ref()
|
||||
.expect("spawn_agent should use object params");
|
||||
|
||||
assert!(!properties.contains_key("agent_type"));
|
||||
assert!(!properties.contains_key("model"));
|
||||
assert!(!properties.contains_key("reasoning_effort"));
|
||||
assert!(!properties.contains_key("service_tier"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -23,6 +23,7 @@ use codex_model_provider::create_model_provider;
|
||||
use codex_model_provider_info::built_in_model_providers;
|
||||
use codex_protocol::AgentPath;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::config_types::ServiceTier;
|
||||
use codex_protocol::config_types::ShellEnvironmentPolicy;
|
||||
use codex_protocol::models::BaseInstructions;
|
||||
use codex_protocol::models::ContentItem;
|
||||
@@ -444,6 +445,311 @@ async fn multi_agent_v2_spawn_defaults_to_full_fork_and_rejects_child_model_over
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_agent_service_tier_override_uses_supported_child_model_tier() {
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SpawnAgentResult {
|
||||
agent_id: String,
|
||||
}
|
||||
|
||||
let (mut session, turn) = make_session_and_context().await;
|
||||
let manager = thread_manager();
|
||||
let root = manager
|
||||
.start_thread((*turn.config).clone())
|
||||
.await
|
||||
.expect("root thread should start");
|
||||
session.services.agent_control = manager.agent_control();
|
||||
session.conversation_id = root.thread_id;
|
||||
|
||||
let output = SpawnAgentHandler::default()
|
||||
.handle(invocation(
|
||||
Arc::new(session),
|
||||
Arc::new(turn),
|
||||
"spawn_agent",
|
||||
function_payload(json!({
|
||||
"message": "inspect this repo",
|
||||
"model": "gpt-5.4",
|
||||
"service_tier": ServiceTier::Fast.request_value()
|
||||
})),
|
||||
))
|
||||
.await
|
||||
.expect("spawn_agent should accept a supported explicit service tier");
|
||||
let (content, _) = expect_text_output(output);
|
||||
let result: SpawnAgentResult =
|
||||
serde_json::from_str(&content).expect("spawn_agent result should be json");
|
||||
let snapshot = manager
|
||||
.get_thread(parse_agent_id(&result.agent_id))
|
||||
.await
|
||||
.expect("spawned agent thread should exist")
|
||||
.config_snapshot()
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
snapshot.service_tier,
|
||||
Some(ServiceTier::Fast.request_value().to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_agent_service_tier_override_rejects_unknown_tier() {
|
||||
let (session, turn) = make_session_and_context().await;
|
||||
let err = SpawnAgentHandler::default()
|
||||
.handle(invocation(
|
||||
Arc::new(session),
|
||||
Arc::new(turn),
|
||||
"spawn_agent",
|
||||
function_payload(json!({
|
||||
"message": "inspect this repo",
|
||||
"model": "gpt-5.4",
|
||||
"service_tier": "turbo"
|
||||
})),
|
||||
))
|
||||
.await
|
||||
.expect_err("unknown service tier should be rejected");
|
||||
|
||||
assert_eq!(
|
||||
err,
|
||||
FunctionCallError::RespondToModel(
|
||||
"Service tier `turbo` is not supported for model `gpt-5.4`. Supported service tiers: priority"
|
||||
.to_string()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_agent_service_tier_override_rejects_tier_unsupported_by_child_model() {
|
||||
let (session, turn) = make_session_and_context().await;
|
||||
let err = SpawnAgentHandler::default()
|
||||
.handle(invocation(
|
||||
Arc::new(session),
|
||||
Arc::new(turn),
|
||||
"spawn_agent",
|
||||
function_payload(json!({
|
||||
"message": "inspect this repo",
|
||||
"model": "gpt-5.3-codex",
|
||||
"service_tier": ServiceTier::Fast.request_value()
|
||||
})),
|
||||
))
|
||||
.await
|
||||
.expect_err("tier unsupported by the final child model should be rejected");
|
||||
|
||||
assert_eq!(
|
||||
err,
|
||||
FunctionCallError::RespondToModel(
|
||||
"Service tier `priority` is not supported for model `gpt-5.3-codex`. Supported service tiers: none"
|
||||
.to_string()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_agent_inherits_supported_parent_service_tier() {
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SpawnAgentResult {
|
||||
agent_id: String,
|
||||
}
|
||||
|
||||
let (mut session, turn) = make_session_and_context().await;
|
||||
let mut turn = turn
|
||||
.with_model("gpt-5.4".to_string(), &session.services.models_manager)
|
||||
.await;
|
||||
let mut config = (*turn.config).clone();
|
||||
config.service_tier = Some(ServiceTier::Fast.request_value().to_string());
|
||||
turn.config = Arc::new(config);
|
||||
let manager = thread_manager();
|
||||
let root = manager
|
||||
.start_thread((*turn.config).clone())
|
||||
.await
|
||||
.expect("root thread should start");
|
||||
session.services.agent_control = manager.agent_control();
|
||||
session.conversation_id = root.thread_id;
|
||||
|
||||
let output = SpawnAgentHandler::default()
|
||||
.handle(invocation(
|
||||
Arc::new(session),
|
||||
Arc::new(turn),
|
||||
"spawn_agent",
|
||||
function_payload(json!({"message": "inspect this repo"})),
|
||||
))
|
||||
.await
|
||||
.expect("spawn_agent should inherit a supported parent service tier");
|
||||
let (content, _) = expect_text_output(output);
|
||||
let result: SpawnAgentResult =
|
||||
serde_json::from_str(&content).expect("spawn_agent result should be json");
|
||||
let snapshot = manager
|
||||
.get_thread(parse_agent_id(&result.agent_id))
|
||||
.await
|
||||
.expect("spawned agent thread should exist")
|
||||
.config_snapshot()
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
snapshot.service_tier,
|
||||
Some(ServiceTier::Fast.request_value().to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_agent_clears_inherited_service_tier_when_child_model_does_not_support_it() {
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SpawnAgentResult {
|
||||
agent_id: String,
|
||||
}
|
||||
|
||||
let (mut session, turn) = make_session_and_context().await;
|
||||
let mut turn = turn
|
||||
.with_model("gpt-5.4".to_string(), &session.services.models_manager)
|
||||
.await;
|
||||
let mut config = (*turn.config).clone();
|
||||
config.service_tier = Some(ServiceTier::Fast.request_value().to_string());
|
||||
turn.config = Arc::new(config);
|
||||
let manager = thread_manager();
|
||||
let root = manager
|
||||
.start_thread((*turn.config).clone())
|
||||
.await
|
||||
.expect("root thread should start");
|
||||
session.services.agent_control = manager.agent_control();
|
||||
session.conversation_id = root.thread_id;
|
||||
|
||||
let output = SpawnAgentHandler::default()
|
||||
.handle(invocation(
|
||||
Arc::new(session),
|
||||
Arc::new(turn),
|
||||
"spawn_agent",
|
||||
function_payload(json!({
|
||||
"message": "inspect this repo",
|
||||
"model": "gpt-5.3-codex"
|
||||
})),
|
||||
))
|
||||
.await
|
||||
.expect("spawn_agent should clear unsupported inherited service tier");
|
||||
let (content, _) = expect_text_output(output);
|
||||
let result: SpawnAgentResult =
|
||||
serde_json::from_str(&content).expect("spawn_agent result should be json");
|
||||
let snapshot = manager
|
||||
.get_thread(parse_agent_id(&result.agent_id))
|
||||
.await
|
||||
.expect("spawned agent thread should exist")
|
||||
.config_snapshot()
|
||||
.await;
|
||||
|
||||
assert_eq!(snapshot.service_tier, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_agent_full_history_fork_accepts_explicit_service_tier() {
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SpawnAgentResult {
|
||||
agent_id: String,
|
||||
}
|
||||
|
||||
let (mut session, turn) = make_session_and_context().await;
|
||||
let turn = turn
|
||||
.with_model("gpt-5.4".to_string(), &session.services.models_manager)
|
||||
.await;
|
||||
let manager = thread_manager();
|
||||
let root = manager
|
||||
.start_thread((*turn.config).clone())
|
||||
.await
|
||||
.expect("root thread should start");
|
||||
session.services.agent_control = manager.agent_control();
|
||||
session.conversation_id = root.thread_id;
|
||||
|
||||
let output = SpawnAgentHandler::default()
|
||||
.handle(invocation(
|
||||
Arc::new(session),
|
||||
Arc::new(turn),
|
||||
"spawn_agent",
|
||||
function_payload(json!({
|
||||
"message": "inspect this repo",
|
||||
"fork_context": true,
|
||||
"service_tier": ServiceTier::Fast.request_value()
|
||||
})),
|
||||
))
|
||||
.await
|
||||
.expect("full-history fork should accept explicit service tier");
|
||||
let (content, _) = expect_text_output(output);
|
||||
let result: SpawnAgentResult =
|
||||
serde_json::from_str(&content).expect("spawn_agent result should be json");
|
||||
let snapshot = manager
|
||||
.get_thread(parse_agent_id(&result.agent_id))
|
||||
.await
|
||||
.expect("spawned agent thread should exist")
|
||||
.config_snapshot()
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
snapshot.service_tier,
|
||||
Some(ServiceTier::Fast.request_value().to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_agent_v2_full_history_fork_accepts_explicit_service_tier() {
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SpawnAgentResult {
|
||||
task_name: String,
|
||||
}
|
||||
|
||||
let (mut session, turn) = make_session_and_context().await;
|
||||
let mut turn = turn
|
||||
.with_model("gpt-5.4".to_string(), &session.services.models_manager)
|
||||
.await;
|
||||
let mut config = (*turn.config).clone();
|
||||
config
|
||||
.features
|
||||
.enable(Feature::MultiAgentV2)
|
||||
.expect("test config should allow feature update");
|
||||
turn.config = Arc::new(config);
|
||||
let manager = thread_manager();
|
||||
let root = manager
|
||||
.start_thread((*turn.config).clone())
|
||||
.await
|
||||
.expect("root thread should start");
|
||||
session.services.agent_control = manager.agent_control();
|
||||
session.conversation_id = root.thread_id;
|
||||
let session = Arc::new(session);
|
||||
let turn = Arc::new(turn);
|
||||
|
||||
let output = SpawnAgentHandlerV2::default()
|
||||
.handle(invocation(
|
||||
session.clone(),
|
||||
turn.clone(),
|
||||
"spawn_agent",
|
||||
function_payload(json!({
|
||||
"message": "inspect this repo",
|
||||
"task_name": "fork_with_tier",
|
||||
"service_tier": ServiceTier::Fast.request_value()
|
||||
})),
|
||||
))
|
||||
.await
|
||||
.expect("multi-agent v2 full-history fork should accept explicit service tier");
|
||||
let (content, _) = expect_text_output(output);
|
||||
let result: SpawnAgentResult =
|
||||
serde_json::from_str(&content).expect("spawn_agent result should be json");
|
||||
let child_thread_id = session
|
||||
.services
|
||||
.agent_control
|
||||
.resolve_agent_reference(
|
||||
session.conversation_id,
|
||||
&turn.session_source,
|
||||
result.task_name.as_str(),
|
||||
)
|
||||
.await
|
||||
.expect("spawned task name should resolve");
|
||||
let snapshot = manager
|
||||
.get_thread(child_thread_id)
|
||||
.await
|
||||
.expect("spawned agent thread should exist")
|
||||
.config_snapshot()
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
snapshot.service_tier,
|
||||
Some(ServiceTier::Fast.request_value().to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_agent_v2_spawn_partial_fork_turns_allows_agent_type_override() {
|
||||
let (mut session, mut turn) = make_session_and_context().await;
|
||||
|
||||
@@ -100,6 +100,13 @@ impl ToolHandler for Handler {
|
||||
.await
|
||||
.map_err(FunctionCallError::RespondToModel)?;
|
||||
}
|
||||
apply_spawn_agent_service_tier(
|
||||
&session,
|
||||
&mut config,
|
||||
turn.config.service_tier.as_deref(),
|
||||
args.service_tier.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?;
|
||||
apply_spawn_agent_overrides(&mut config, child_depth);
|
||||
|
||||
@@ -236,6 +243,7 @@ struct SpawnAgentArgs {
|
||||
agent_type: Option<String>,
|
||||
model: Option<String>,
|
||||
reasoning_effort: Option<ReasoningEffort>,
|
||||
service_tier: Option<String>,
|
||||
fork_turns: Option<String>,
|
||||
fork_context: Option<bool>,
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use codex_models_manager::manager::SharedModelsManager;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
use codex_protocol::openai_models::ConfigShellToolType;
|
||||
use codex_protocol::openai_models::ModelInfo;
|
||||
use codex_protocol::openai_models::ModelServiceTier;
|
||||
use codex_protocol::openai_models::ModelVisibility;
|
||||
use codex_protocol::openai_models::ModelsResponse;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
@@ -52,6 +53,7 @@ fn test_model_info(
|
||||
visibility: ModelVisibility,
|
||||
default_reasoning_level: ReasoningEffort,
|
||||
supported_reasoning_levels: Vec<ReasoningEffortPreset>,
|
||||
service_tiers: Vec<ModelServiceTier>,
|
||||
) -> ModelInfo {
|
||||
ModelInfo {
|
||||
slug: slug.to_string(),
|
||||
@@ -67,7 +69,7 @@ fn test_model_info(
|
||||
supports_search_tool: false,
|
||||
priority: 1,
|
||||
additional_speed_tiers: Vec::new(),
|
||||
service_tiers: Vec::new(),
|
||||
service_tiers,
|
||||
upgrade: None,
|
||||
base_instructions: "base instructions".to_string(),
|
||||
model_messages: None,
|
||||
@@ -126,6 +128,11 @@ async fn spawn_agent_description_lists_visible_models_and_reasoning_efforts() ->
|
||||
description: "Deep dive".to_string(),
|
||||
},
|
||||
],
|
||||
vec![ModelServiceTier {
|
||||
id: "priority".to_string(),
|
||||
name: "Fast".to_string(),
|
||||
description: "1.5x speed, increased usage".to_string(),
|
||||
}],
|
||||
),
|
||||
test_model_info(
|
||||
"hidden-model",
|
||||
@@ -137,6 +144,7 @@ async fn spawn_agent_description_lists_visible_models_and_reasoning_efforts() ->
|
||||
effort: ReasoningEffort::Low,
|
||||
description: "Not visible".to_string(),
|
||||
}],
|
||||
Vec::new(),
|
||||
),
|
||||
],
|
||||
},
|
||||
@@ -195,6 +203,11 @@ async fn spawn_agent_description_lists_visible_models_and_reasoning_efforts() ->
|
||||
description.contains("low (Quick scan), high (Deep dive)."),
|
||||
"expected reasoning efforts in spawn_agent description: {description:?}"
|
||||
);
|
||||
assert!(
|
||||
description
|
||||
.contains("Supported service tiers: priority (Fast: 1.5x speed, increased usage)."),
|
||||
"expected service tier guidance in spawn_agent description: {description:?}"
|
||||
);
|
||||
assert!(
|
||||
!description.contains("Hidden Model"),
|
||||
"hidden picker model should be omitted from spawn_agent description: {description:?}"
|
||||
|
||||
@@ -1,23 +1,29 @@
|
||||
#[path = "enabled_extensions/shared_state_extension.rs"]
|
||||
mod shared_state_extension;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_extension_api::ExtensionData;
|
||||
use codex_extension_api::ExtensionRegistryBuilder;
|
||||
use shared_state_extension::SharedStateExtension;
|
||||
use shared_state_extension::recorded_style_contributions;
|
||||
use shared_state_extension::recorded_usage_contributions;
|
||||
|
||||
fn main() {
|
||||
// 1. Install the contributors for the thread-start input type this host exposes.
|
||||
let mut builder = ExtensionRegistryBuilder::<()>::new();
|
||||
shared_state_extension::install(&mut builder);
|
||||
let registry = builder.build();
|
||||
// 1. Build the extension value owned by the host.
|
||||
let extension = Arc::new(SharedStateExtension);
|
||||
|
||||
// 2. The host decides which stores are shared.
|
||||
// 2. Install it into the registry for the thread-start input type this host exposes.
|
||||
let registry = ExtensionRegistryBuilder::<()>::new()
|
||||
.with_extension(extension)
|
||||
.build();
|
||||
|
||||
// 3. The host decides which stores are shared.
|
||||
let session_store = ExtensionData::new();
|
||||
let first_thread_store = ExtensionData::new();
|
||||
let second_thread_store = ExtensionData::new();
|
||||
|
||||
// 3. Reusing the same session store shares session state across threads.
|
||||
// 4. Reusing the same session store shares session state across threads.
|
||||
let first_thread_fragments = contribute_prompt(®istry, &session_store, &first_thread_store);
|
||||
contribute_prompt(®istry, &session_store, &first_thread_store);
|
||||
contribute_prompt(®istry, &session_store, &second_thread_store);
|
||||
|
||||
@@ -2,15 +2,21 @@ use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicU64;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use codex_extension_api::CodexExtension;
|
||||
use codex_extension_api::ContextContributor;
|
||||
use codex_extension_api::ExtensionData;
|
||||
use codex_extension_api::ExtensionRegistryBuilder;
|
||||
use codex_extension_api::PromptFragment;
|
||||
|
||||
/// Installs the tutorial contributors used by the example host.
|
||||
pub fn install(registry: &mut ExtensionRegistryBuilder<()>) {
|
||||
registry.prompt_contributor(Arc::new(StyleContributor));
|
||||
registry.prompt_contributor(Arc::new(UsageContributor));
|
||||
/// Small tutorial extension that installs two prompt contributors.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct SharedStateExtension;
|
||||
|
||||
impl CodexExtension<()> for SharedStateExtension {
|
||||
fn install(self: Arc<Self>, registry: &mut ExtensionRegistryBuilder<()>) {
|
||||
registry.prompt_contributor(Arc::new(StyleContributor));
|
||||
registry.prompt_contributor(Arc::new(UsageContributor));
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
||||
12
codex-rs/ext/extension-api/src/extension.rs
Normal file
12
codex-rs/ext/extension-api/src/extension.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::ExtensionRegistryBuilder;
|
||||
|
||||
/// First-party extension that can install one or more typed runtime contributions.
|
||||
///
|
||||
/// Implementations should use [`Self::install`] only to register the concrete
|
||||
/// providers they own.
|
||||
pub trait CodexExtension<C>: Send + Sync {
|
||||
/// Registers this extension's concrete typed contributions.
|
||||
fn install(self: Arc<Self>, registry: &mut ExtensionRegistryBuilder<C>);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
mod contributors;
|
||||
mod extension;
|
||||
mod registry;
|
||||
mod state;
|
||||
|
||||
@@ -13,6 +14,7 @@ pub use contributors::ToolContributor;
|
||||
pub use contributors::ToolHandler;
|
||||
pub use contributors::TurnItemContributionFuture;
|
||||
pub use contributors::TurnItemContributor;
|
||||
pub use extension::CodexExtension;
|
||||
pub use registry::ExtensionRegistry;
|
||||
pub use registry::ExtensionRegistryBuilder;
|
||||
pub use registry::empty_extension_registry;
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::ApprovalInterceptorContributor;
|
||||
use crate::CodexExtension;
|
||||
use crate::ContextContributor;
|
||||
use crate::ThreadStartContributor;
|
||||
use crate::ToolContributor;
|
||||
use crate::TurnItemContributor;
|
||||
|
||||
/// Mutable registry used while hosts register typed runtime contributions.
|
||||
/// Mutable registry used while extensions install their typed contributions.
|
||||
pub struct ExtensionRegistryBuilder<C> {
|
||||
thread_start_contributors: Vec<Arc<dyn ThreadStartContributor<C>>>,
|
||||
context_contributors: Vec<Arc<dyn ContextContributor>>,
|
||||
@@ -33,6 +34,24 @@ impl<C> ExtensionRegistryBuilder<C> {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Installs one extension and returns the builder.
|
||||
#[must_use]
|
||||
pub fn with_extension<E>(mut self, extension: Arc<E>) -> Self
|
||||
where
|
||||
E: CodexExtension<C> + 'static,
|
||||
{
|
||||
self.install_extension(extension);
|
||||
self
|
||||
}
|
||||
|
||||
/// Installs one extension into the registry under construction.
|
||||
pub fn install_extension<E>(&mut self, extension: Arc<E>)
|
||||
where
|
||||
E: CodexExtension<C> + 'static,
|
||||
{
|
||||
extension.install(self);
|
||||
}
|
||||
|
||||
/// Registers one approval interceptor contributor.
|
||||
pub fn approval_interceptor_contributor(
|
||||
&mut self,
|
||||
@@ -109,7 +128,7 @@ impl<C> ExtensionRegistry<C> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates an empty shared registry for hosts that do not register contributions.
|
||||
/// Creates an empty shared registry for hosts that do not install extensions.
|
||||
pub fn empty_extension_registry<C>() -> Arc<ExtensionRegistry<C>> {
|
||||
Arc::new(ExtensionRegistryBuilder::new().build())
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_core::config::Config;
|
||||
use codex_extension_api::CodexExtension;
|
||||
use codex_extension_api::ContextContributor;
|
||||
use codex_extension_api::ExtensionData;
|
||||
use codex_extension_api::ExtensionRegistryBuilder;
|
||||
@@ -10,10 +11,17 @@ use codex_features::Feature;
|
||||
|
||||
const DEFAULT_ATTRIBUTION_VALUE: &str = "Codex <noreply@openai.com>";
|
||||
|
||||
/// Contributes the configured git-attribution instruction.
|
||||
/// Prompt-only extension that contributes the configured git-attribution instruction.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct GitAttributionExtension;
|
||||
|
||||
impl GitAttributionExtension {
|
||||
/// Creates an extension instance.
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl ContextContributor for GitAttributionExtension {
|
||||
fn contribute(
|
||||
&self,
|
||||
@@ -53,11 +61,16 @@ impl ThreadStartContributor<Config> for GitAttributionExtension {
|
||||
}
|
||||
}
|
||||
|
||||
/// Installs the git-attribution contributors into the extension registry.
|
||||
pub fn install(registry: &mut ExtensionRegistryBuilder<Config>) {
|
||||
let extension = Arc::new(GitAttributionExtension);
|
||||
registry.thread_start_contributor(extension.clone());
|
||||
registry.prompt_contributor(extension);
|
||||
impl CodexExtension<Config> for GitAttributionExtension {
|
||||
fn install(self: Arc<Self>, registry: &mut ExtensionRegistryBuilder<Config>) {
|
||||
registry.thread_start_contributor(self.clone());
|
||||
registry.prompt_contributor(self);
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a shared git-attribution extension instance.
|
||||
pub fn extension() -> Arc<GitAttributionExtension> {
|
||||
Arc::new(GitAttributionExtension::new())
|
||||
}
|
||||
|
||||
fn build_commit_message_trailer(config_attribution: Option<&str>) -> Option<String> {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"models": [
|
||||
{
|
||||
{
|
||||
"prefer_websockets": true,
|
||||
"support_verbosity": true,
|
||||
"default_verbosity": "low",
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
load("//:defs.bzl", "codex_rust_crate")
|
||||
|
||||
codex_rust_crate(
|
||||
name = "tool-api",
|
||||
crate_name = "codex_tool_api",
|
||||
)
|
||||
@@ -1,23 +0,0 @@
|
||||
[package]
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
name = "codex-tool-api"
|
||||
version.workspace = true
|
||||
|
||||
[lib]
|
||||
name = "codex_tool_api"
|
||||
path = "src/lib.rs"
|
||||
doctest = false
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
codex-protocol = { workspace = true }
|
||||
codex-tools = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = { workspace = true }
|
||||
@@ -1,127 +0,0 @@
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_tools::ToolName;
|
||||
use codex_tools::ToolSpec;
|
||||
|
||||
use crate::ToolCall;
|
||||
use crate::ToolError;
|
||||
use crate::ToolOutput;
|
||||
|
||||
/// Future returned by one executable-tool invocation.
|
||||
pub type ToolFuture<'a> =
|
||||
Pin<Box<dyn Future<Output = Result<Box<dyn ToolOutput>, ToolError>> + Send + 'a>>;
|
||||
|
||||
/// Future returned by one mutability probe.
|
||||
pub type BoolFuture<'a> = Pin<Box<dyn Future<Output = bool> + Send + 'a>>;
|
||||
|
||||
/// Model-visible definition plus executable implementation for one tool.
|
||||
#[derive(Clone)]
|
||||
pub struct ToolBundle<C> {
|
||||
definition: ToolDefinition,
|
||||
executor: Arc<dyn ToolExecutor<C>>,
|
||||
}
|
||||
|
||||
impl<C> ToolBundle<C> {
|
||||
/// Creates one executable tool bundle.
|
||||
pub fn new(name: ToolName, spec: ToolSpec, executor: Arc<dyn ToolExecutor<C>>) -> Self {
|
||||
Self {
|
||||
definition: ToolDefinition {
|
||||
name,
|
||||
spec,
|
||||
supports_parallel_tool_calls: false,
|
||||
},
|
||||
executor,
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks this tool as safe for the host to run in parallel with peers.
|
||||
#[must_use]
|
||||
pub fn allow_parallel_calls(mut self) -> Self {
|
||||
self.definition.supports_parallel_tool_calls = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns the model-visible tool definition.
|
||||
pub fn definition(&self) -> &ToolDefinition {
|
||||
&self.definition
|
||||
}
|
||||
|
||||
/// Returns the executable implementation.
|
||||
pub fn executor(&self) -> Arc<dyn ToolExecutor<C>> {
|
||||
Arc::clone(&self.executor)
|
||||
}
|
||||
}
|
||||
|
||||
/// Model-visible metadata owned by an executable tool bundle.
|
||||
#[derive(Clone)]
|
||||
pub struct ToolDefinition {
|
||||
pub name: ToolName,
|
||||
pub spec: ToolSpec,
|
||||
pub supports_parallel_tool_calls: bool,
|
||||
}
|
||||
|
||||
/// Executable behavior for one contributed tool.
|
||||
///
|
||||
/// Implementations should keep host-specific needs inside `C`; tool owners that
|
||||
/// do not require host state can implement the trait for any `C`.
|
||||
pub trait ToolExecutor<C>: Send + Sync {
|
||||
fn execute<'a>(&'a self, call: ToolCall<C>) -> ToolFuture<'a>;
|
||||
|
||||
/// Returns whether the call may mutate user state.
|
||||
///
|
||||
/// Hosts can use this conservative signal for serialization or approval
|
||||
/// policy. Read-only tools should override this default.
|
||||
fn is_mutating<'a>(&'a self, _call: &'a ToolCall<C>) -> BoolFuture<'a> {
|
||||
Box::pin(async { true })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
use std::task::Context;
|
||||
use std::task::Poll;
|
||||
use std::task::Wake;
|
||||
use std::task::Waker;
|
||||
|
||||
use super::*;
|
||||
use crate::JsonToolOutput;
|
||||
use crate::ToolInput;
|
||||
|
||||
struct DefaultMutatingExecutor;
|
||||
|
||||
impl ToolExecutor<()> for DefaultMutatingExecutor {
|
||||
fn execute<'a>(&'a self, _call: ToolCall<()>) -> ToolFuture<'a> {
|
||||
Box::pin(async {
|
||||
Ok(Box::new(JsonToolOutput::new(serde_json::json!(null))) as Box<dyn ToolOutput>)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct NoopWaker;
|
||||
|
||||
impl Wake for NoopWaker {
|
||||
fn wake(self: Arc<Self>) {}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contributed_tools_default_to_mutating() {
|
||||
let call = ToolCall {
|
||||
context: (),
|
||||
call_id: "call-default-mutating".to_string(),
|
||||
input: ToolInput::Function {
|
||||
arguments: "{}".to_string(),
|
||||
},
|
||||
};
|
||||
let mut future = DefaultMutatingExecutor.is_mutating(&call);
|
||||
let waker = Waker::from(Arc::new(NoopWaker));
|
||||
let mut context = Context::from_waker(&waker);
|
||||
|
||||
assert!(matches!(
|
||||
future.as_mut().poll(&mut context),
|
||||
Poll::Ready(true)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
/// One executable tool call delivered to a contributed tool.
|
||||
pub struct ToolCall<C> {
|
||||
pub context: C,
|
||||
pub call_id: String,
|
||||
pub input: ToolInput,
|
||||
}
|
||||
|
||||
/// Model-supplied input for the executable tool families currently exposed by
|
||||
/// the shared tool seam.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ToolInput {
|
||||
Function { arguments: String },
|
||||
Freeform { input: String },
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
use thiserror::Error;
|
||||
|
||||
/// Error returned by a contributed executable tool.
|
||||
#[derive(Clone, Debug, Error, PartialEq, Eq)]
|
||||
pub enum ToolError {
|
||||
#[error("{0}")]
|
||||
RespondToModel(String),
|
||||
#[error("fatal tool error: {0}")]
|
||||
Fatal(String),
|
||||
}
|
||||
|
||||
impl ToolError {
|
||||
/// Creates a model-visible tool error.
|
||||
pub fn respond_to_model(message: impl Into<String>) -> Self {
|
||||
Self::RespondToModel(message.into())
|
||||
}
|
||||
|
||||
/// Creates a host-fatal tool error.
|
||||
pub fn fatal(message: impl Into<String>) -> Self {
|
||||
Self::Fatal(message.into())
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
//! Reusable executable-tool contracts shared between hosts and tool owners.
|
||||
|
||||
mod bundle;
|
||||
mod call;
|
||||
mod error;
|
||||
mod output;
|
||||
|
||||
pub use bundle::BoolFuture;
|
||||
pub use bundle::ToolBundle;
|
||||
pub use bundle::ToolDefinition;
|
||||
pub use bundle::ToolExecutor;
|
||||
pub use bundle::ToolFuture;
|
||||
pub use call::ToolCall;
|
||||
pub use call::ToolInput;
|
||||
pub use error::ToolError;
|
||||
pub use output::JsonToolOutput;
|
||||
pub use output::ToolOutput;
|
||||
@@ -1,113 +0,0 @@
|
||||
use codex_protocol::models::FunctionCallOutputBody;
|
||||
use codex_protocol::models::FunctionCallOutputPayload;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::ToolError;
|
||||
use crate::ToolInput;
|
||||
|
||||
/// Tool-owned output rendering for each host-facing boundary.
|
||||
pub trait ToolOutput: Send {
|
||||
fn log_preview(&self) -> String;
|
||||
|
||||
fn success_for_logging(&self) -> bool;
|
||||
|
||||
fn to_response_item(&self, call_id: &str, input: &ToolInput) -> ResponseInputItem;
|
||||
|
||||
/// Returns the stable value exposed to post-tool-use hook integration when a
|
||||
/// host chooses to wire that surface for this tool.
|
||||
fn post_tool_use_response(&self, _call_id: &str, _input: &ToolInput) -> Option<Value> {
|
||||
None
|
||||
}
|
||||
|
||||
fn code_mode_result(&self, input: &ToolInput) -> Value;
|
||||
}
|
||||
|
||||
/// Convenience output for ordinary JSON-returning function tools.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct JsonToolOutput {
|
||||
value: Value,
|
||||
}
|
||||
|
||||
impl JsonToolOutput {
|
||||
/// Creates a JSON output from a serializable value.
|
||||
pub fn from_serializable(value: impl Serialize) -> Result<Self, ToolError> {
|
||||
serde_json::to_value(value).map(Self::new).map_err(|err| {
|
||||
ToolError::respond_to_model(format!("failed to serialize output: {err}"))
|
||||
})
|
||||
}
|
||||
|
||||
/// Creates a JSON output from an already materialized value.
|
||||
pub fn new(value: Value) -> Self {
|
||||
Self { value }
|
||||
}
|
||||
}
|
||||
|
||||
impl ToolOutput for JsonToolOutput {
|
||||
fn log_preview(&self) -> String {
|
||||
self.value.to_string()
|
||||
}
|
||||
|
||||
fn success_for_logging(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn to_response_item(&self, call_id: &str, _input: &ToolInput) -> ResponseInputItem {
|
||||
ResponseInputItem::FunctionCallOutput {
|
||||
call_id: call_id.to_string(),
|
||||
output: FunctionCallOutputPayload {
|
||||
body: FunctionCallOutputBody::Text(self.value.to_string()),
|
||||
success: Some(true),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn post_tool_use_response(&self, _call_id: &str, _input: &ToolInput) -> Option<Value> {
|
||||
Some(self.value.clone())
|
||||
}
|
||||
|
||||
fn code_mode_result(&self, _input: &ToolInput) -> Value {
|
||||
self.value.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use codex_protocol::models::FunctionCallOutputBody;
|
||||
use codex_protocol::models::FunctionCallOutputPayload;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
|
||||
use super::JsonToolOutput;
|
||||
use super::ToolOutput;
|
||||
use crate::ToolInput;
|
||||
|
||||
#[test]
|
||||
fn json_tool_output_renders_function_output() {
|
||||
let input = ToolInput::Function {
|
||||
arguments: "{}".to_string(),
|
||||
};
|
||||
let output = JsonToolOutput::from_serializable(json!({ "ok": true }))
|
||||
.expect("serializable value should produce json output");
|
||||
|
||||
assert_eq!(output.log_preview(), "{\"ok\":true}");
|
||||
assert!(output.success_for_logging());
|
||||
assert_eq!(
|
||||
output.to_response_item("call-1", &input),
|
||||
ResponseInputItem::FunctionCallOutput {
|
||||
call_id: "call-1".to_string(),
|
||||
output: FunctionCallOutputPayload {
|
||||
body: FunctionCallOutputBody::Text("{\"ok\":true}".to_string()),
|
||||
success: Some(true),
|
||||
},
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
output.post_tool_use_response("call-1", &input),
|
||||
Some(json!({ "ok": true }))
|
||||
);
|
||||
assert_eq!(output.code_mode_result(&input), json!({ "ok": true }));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user