Compare commits

..

14 Commits

Author SHA1 Message Date
Michael Bolin
dc263f5926 ci: block new workspace crate features (#16455)
## Why

We already enforce workspace metadata and lint inheritance for
`codex-rs` manifests, but we still allow new crate features to slip into
the workspace. That makes it too easy to add more Cargo-only feature
permutations while we are trying to eliminate them.

## What changed

- extend `verify_cargo_workspace_manifests.py` to reject new
`[features]` tables in workspace crates
- reject new optional dependencies that create implicit crate features
- reject new workspace-to-workspace `features = [...]` activations and
`default-features = false`
- add a narrow temporary allowlist for the existing feature-bearing
manifests and internal feature activations
- make the allowlist self-shrinking so a follow-up removal has to delete
its corresponding exception


---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/openai/codex/pull/16455).
* #16457
* #16456
* __->__ #16455
2026-04-01 11:06:36 -07:00
Peter Meyers
e8d5c6b446 Make fuzzy file search case insensitive (#15772)
Makes fuzzy file search use case-insensitive matching instead of
smart-case in `codex-file-search`. I find smart-case to be a poor user
experience -using the wrong case for a letter drops its match so
significantly, it often drops off the results list, effectively making a
search case-sensitive.
2026-04-01 14:04:33 -04:00
Michael Bolin
75365bf718 fix: remove unused import (#16449)
https://github.com/openai/codex/pull/16433 resulted in an unused import
inside `mod tests`. This is flagged by `cargo clippy --tests`, which is
run as part of
https://github.com/openai/codex/actions/workflows/rust-ci-full.yml, but
is not caught by our current Bazel setup for clippy.

Fixing this ASAP to get
https://github.com/openai/codex/actions/workflows/rust-ci-full.yml green
again, but am looking at fixing the Bazel workflow in parallel.
2026-04-01 09:14:29 -07:00
Michael Bolin
5cca5c0093 docs: update argument_comment_lint instructions in AGENTS.md (#16375)
I noticed that Codex was spending more time on running this lint check
locally than I would like. Now that we have the linter running
cross-platform using Bazel in CI, I find it's best just to update the PR
ASAP to get CI going than to wait for `just argument-comment-lint` to
finish locally before updating the PR.
2026-04-01 15:44:34 +00:00
Dylan Hurd
d3b99ef110 fix(core) rm execute_exec_request sandbox_policy (#16422)
## Summary
In #11871 we started consolidating on ExecRequest.sandbox_policy instead
of passing in a separate policy object that theoretically could differ
(but did not). This finishes the some parameter cleanup.

This should be a simple noop, since all 3 callsites of this function
already used a cloned object from the ExecRequest value.

## Testing
- [x] Existing tests pass
2026-04-01 11:03:48 -04:00
jif-oai
f839f3ff2e feat: auto vaccum state DB (#16434)
Start with a full vaccum the first time, then auto-vaccum incremental
2026-04-01 16:46:21 +02:00
jif-oai
c846a57d03 chore: drop log DB (#16433)
Drop the log table from the state DB
2026-04-01 15:49:17 +02:00
jif-oai
5bbfee69b6 nit: deny field v2 (#16427) 2026-04-01 12:26:40 +02:00
jif-oai
609ac0c7ab chore: interrupted as state (#16426) 2026-04-01 12:26:29 +02:00
jif-oai
df5f79da36 nit: update wait v2 desc (#16425) 2026-04-01 12:26:25 +02:00
jif-oai
0c776c433b feat: tasks can't be assigned to root agent (#16424) 2026-04-01 12:18:50 +02:00
jif-oai
3152d1a557 Use message string in v2 assign_task (#16419)
Fix assign task and clean everything

---------

Co-authored-by: Codex <noreply@openai.com>
2026-04-01 11:40:19 +02:00
jif-oai
23d638a573 Use message string in v2 send_message (#16409)
## Summary
- switch MultiAgentV2 send_message to accept a single message string
instead of items
- keep the old assign_task item parser in place for the next branch
- update send_message schema/spec and focused handler tests

## Verification
- cargo test -p codex-tools
send_message_tool_requires_message_and_uses_submission_output
- cargo test -p codex-core multi_agent_v2_send_message
- just fix -p codex-tools
- just fix -p codex-core
- just argument-comment-lint

---------

Co-authored-by: Codex <noreply@openai.com>
2026-04-01 11:26:22 +02:00
jif-oai
d0474f2bc1 Use message string in v2 spawn_agent (#16406)
## Summary
- switch MultiAgentV2 spawn_agent to accept a single message string
instead of items
- update v2 spawn tool schema and focused handler/spec tests

## Verification
- cargo test -p codex-tools
spawn_agent_tool_v2_requires_task_name_and_lists_visible_models
- cargo test -p codex-core multi_agent_v2_spawn
- just fix -p codex-tools
- just fix -p codex-core
- just argument-comment-lint

Co-authored-by: Codex <noreply@openai.com>
2026-04-01 11:26:12 +02:00
23 changed files with 728 additions and 300 deletions

View File

@@ -1,11 +1,13 @@
#!/usr/bin/env python3
"""Verify that codex-rs crates inherit workspace metadata, lints, and names.
"""Verify that codex-rs Cargo manifests follow workspace manifest policy.
This keeps `cargo clippy` aligned with the workspace lint policy by ensuring
each crate opts into `[lints] workspace = true`, and it also checks the crate
name conventions for top-level `codex-rs/*` crates and `codex-rs/utils/*`
crates.
Checks:
- Crates inherit `[workspace.package]` metadata.
- Crates opt into `[lints] workspace = true`.
- Crate names follow the codex-rs directory naming conventions.
- Workspace manifests do not introduce new workspace crate feature toggles
while the remaining exceptions are being removed.
"""
from __future__ import annotations
@@ -24,20 +26,79 @@ TOP_LEVEL_NAME_EXCEPTIONS = {
UTILITY_NAME_EXCEPTIONS = {
"path-utils": "codex-utils-path",
}
MANIFEST_FEATURE_EXCEPTIONS = {
"codex-rs/cloud-tasks-client/Cargo.toml": {
"default": ("online",),
"online": ("dep:codex-backend-client",),
"mock": (),
},
"codex-rs/otel/Cargo.toml": {
"disable-default-metrics-exporter": (),
},
"codex-rs/tui/Cargo.toml": {
"default": ("voice-input",),
"vt100-tests": (),
"debug-logs": (),
"voice-input": ("dep:cpal",),
},
}
OPTIONAL_DEPENDENCY_EXCEPTIONS = {
(
"codex-rs/cloud-tasks-client/Cargo.toml",
"dependencies",
"codex-backend-client",
),
(
"codex-rs/tui/Cargo.toml",
'target.cfg(not(target_os = "linux")).dependencies',
"cpal",
),
}
INTERNAL_DEPENDENCY_FEATURE_EXCEPTIONS = {
(
"codex-rs/cloud-tasks/Cargo.toml",
"dependencies",
"codex-cloud-tasks-client",
): ("mock", "online"),
(
"codex-rs/core/Cargo.toml",
"dev-dependencies",
"codex-otel",
): ("disable-default-metrics-exporter",),
}
def main() -> int:
failures = [
(path.relative_to(ROOT), errors)
for path in cargo_manifests()
if (errors := manifest_errors(path))
]
if not failures:
internal_package_names = workspace_package_names()
used_manifest_feature_exceptions: set[str] = set()
used_optional_dependency_exceptions: set[tuple[str, str, str]] = set()
used_internal_dependency_feature_exceptions: set[tuple[str, str, str]] = set()
failures_by_path: dict[str, list[str]] = {}
for path in manifests_to_verify():
if errors := manifest_errors(
path,
internal_package_names,
used_manifest_feature_exceptions,
used_optional_dependency_exceptions,
used_internal_dependency_feature_exceptions,
):
failures_by_path[manifest_key(path)] = errors
add_unused_exception_errors(
failures_by_path,
used_manifest_feature_exceptions,
used_optional_dependency_exceptions,
used_internal_dependency_feature_exceptions,
)
if not failures_by_path:
return 0
print(
"Cargo manifests under codex-rs must inherit workspace package metadata and "
"opt into workspace lints."
"Cargo manifests under codex-rs must inherit workspace package metadata, "
"opt into workspace lints, and avoid introducing new workspace crate "
"features."
)
print(
"Cargo only applies `codex-rs/Cargo.toml` `[workspace.lints.clippy]` "
@@ -56,8 +117,13 @@ def main() -> int:
"Package-name checks apply to `codex-rs/<crate>/Cargo.toml` and "
"`codex-rs/utils/<crate>/Cargo.toml`."
)
print(
"Existing workspace crate features are temporarily allowlisted in this "
"script and must shrink as they are removed."
)
print()
for path, errors in failures:
for path in sorted(failures_by_path):
errors = failures_by_path[path]
print(f"{path}:")
for error in errors:
print(f" - {error}")
@@ -65,28 +131,106 @@ def main() -> int:
return 1
def manifest_errors(path: Path) -> list[str]:
def manifest_errors(
path: Path,
internal_package_names: set[str],
used_manifest_feature_exceptions: set[str],
used_optional_dependency_exceptions: set[tuple[str, str, str]],
used_internal_dependency_feature_exceptions: set[tuple[str, str, str]],
) -> list[str]:
manifest = load_manifest(path)
package = manifest.get("package")
if not isinstance(package, dict):
if not isinstance(package, dict) and path != CARGO_RS_ROOT / "Cargo.toml":
return []
errors = []
for field in WORKSPACE_PACKAGE_FIELDS:
if not is_workspace_reference(package.get(field)):
errors.append(f"set `{field}.workspace = true` in `[package]`")
if isinstance(package, dict):
for field in WORKSPACE_PACKAGE_FIELDS:
if not is_workspace_reference(package.get(field)):
errors.append(f"set `{field}.workspace = true` in `[package]`")
lints = manifest.get("lints")
if not (isinstance(lints, dict) and lints.get("workspace") is True):
errors.append("add `[lints]` with `workspace = true`")
lints = manifest.get("lints")
if not (isinstance(lints, dict) and lints.get("workspace") is True):
errors.append("add `[lints]` with `workspace = true`")
expected_name = expected_package_name(path)
if expected_name is not None:
actual_name = package.get("name")
if actual_name != expected_name:
expected_name = expected_package_name(path)
if expected_name is not None:
actual_name = package.get("name")
if actual_name != expected_name:
errors.append(
f"set `[package].name` to `{expected_name}` (found `{actual_name}`)"
)
path_key = manifest_key(path)
features = manifest.get("features")
if features is not None:
normalized_features = normalize_feature_mapping(features)
expected_features = MANIFEST_FEATURE_EXCEPTIONS.get(path_key)
if expected_features is None:
errors.append(
f"set `[package].name` to `{expected_name}` (found `{actual_name}`)"
"remove `[features]`; new workspace crate features are not allowed"
)
else:
used_manifest_feature_exceptions.add(path_key)
if normalized_features != expected_features:
errors.append(
"limit `[features]` to the existing exception list while "
"workspace crate features are being removed "
f"(expected {render_feature_mapping(expected_features)})"
)
for section_name, dependencies in dependency_sections(manifest):
for dependency_name, dependency in dependencies.items():
if not isinstance(dependency, dict):
continue
if dependency.get("optional") is True:
exception_key = (path_key, section_name, dependency_name)
if exception_key in OPTIONAL_DEPENDENCY_EXCEPTIONS:
used_optional_dependency_exceptions.add(exception_key)
else:
errors.append(
"remove `optional = true` from "
f"`{dependency_entry_label(section_name, dependency_name)}`; "
"new optional dependencies are not allowed because they "
"create crate features"
)
if not is_internal_dependency(path, dependency_name, dependency, internal_package_names):
continue
dependency_features = dependency.get("features")
if dependency_features is not None:
normalized_dependency_features = normalize_string_list(
dependency_features
)
exception_key = (path_key, section_name, dependency_name)
expected_dependency_features = (
INTERNAL_DEPENDENCY_FEATURE_EXCEPTIONS.get(exception_key)
)
if expected_dependency_features is None:
errors.append(
"remove `features = [...]` from workspace dependency "
f"`{dependency_entry_label(section_name, dependency_name)}`; "
"new workspace crate feature activations are not allowed"
)
else:
used_internal_dependency_feature_exceptions.add(exception_key)
if normalized_dependency_features != expected_dependency_features:
errors.append(
"limit workspace dependency features on "
f"`{dependency_entry_label(section_name, dependency_name)}` "
"to the existing exception list while workspace crate "
"features are being removed "
f"(expected {render_string_list(expected_dependency_features)})"
)
if dependency.get("default-features") is False:
errors.append(
"remove `default-features = false` from workspace dependency "
f"`{dependency_entry_label(section_name, dependency_name)}`; "
"new workspace crate feature toggles are not allowed"
)
return errors
@@ -109,6 +253,153 @@ def is_workspace_reference(value: object) -> bool:
return isinstance(value, dict) and value.get("workspace") is True
def manifest_key(path: Path) -> str:
return str(path.relative_to(ROOT))
def normalize_feature_mapping(value: object) -> dict[str, tuple[str, ...]] | None:
if not isinstance(value, dict):
return None
normalized = {}
for key, features in value.items():
if not isinstance(key, str):
return None
normalized_features = normalize_string_list(features)
if normalized_features is None:
return None
normalized[key] = normalized_features
return normalized
def normalize_string_list(value: object) -> tuple[str, ...] | None:
if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
return None
return tuple(value)
def render_feature_mapping(features: dict[str, tuple[str, ...]]) -> str:
entries = [
f"{name} = {render_string_list(items)}" for name, items in features.items()
]
return ", ".join(entries)
def render_string_list(items: tuple[str, ...]) -> str:
return "[" + ", ".join(f'"{item}"' for item in items) + "]"
def dependency_sections(manifest: dict) -> list[tuple[str, dict]]:
sections = []
for section_name in ("dependencies", "dev-dependencies", "build-dependencies"):
dependencies = manifest.get(section_name)
if isinstance(dependencies, dict):
sections.append((section_name, dependencies))
workspace = manifest.get("workspace")
if isinstance(workspace, dict):
workspace_dependencies = workspace.get("dependencies")
if isinstance(workspace_dependencies, dict):
sections.append(("workspace.dependencies", workspace_dependencies))
target = manifest.get("target")
if not isinstance(target, dict):
return sections
for target_name, tables in target.items():
if not isinstance(tables, dict):
continue
for section_name in ("dependencies", "dev-dependencies", "build-dependencies"):
dependencies = tables.get(section_name)
if isinstance(dependencies, dict):
sections.append((f"target.{target_name}.{section_name}", dependencies))
return sections
def dependency_entry_label(section_name: str, dependency_name: str) -> str:
return f"[{section_name}].{dependency_name}"
def is_internal_dependency(
manifest_path: Path,
dependency_name: str,
dependency: dict,
internal_package_names: set[str],
) -> bool:
package_name = dependency.get("package", dependency_name)
if isinstance(package_name, str) and package_name in internal_package_names:
return True
dependency_path = dependency.get("path")
if not isinstance(dependency_path, str):
return False
resolved_dependency_path = (manifest_path.parent / dependency_path).resolve()
try:
resolved_dependency_path.relative_to(CARGO_RS_ROOT)
except ValueError:
return False
return True
def add_unused_exception_errors(
failures_by_path: dict[str, list[str]],
used_manifest_feature_exceptions: set[str],
used_optional_dependency_exceptions: set[tuple[str, str, str]],
used_internal_dependency_feature_exceptions: set[tuple[str, str, str]],
) -> None:
for path_key in sorted(
set(MANIFEST_FEATURE_EXCEPTIONS) - used_manifest_feature_exceptions
):
add_failure(
failures_by_path,
path_key,
"remove the stale `[features]` exception from "
"`MANIFEST_FEATURE_EXCEPTIONS`",
)
for path_key, section_name, dependency_name in sorted(
OPTIONAL_DEPENDENCY_EXCEPTIONS - used_optional_dependency_exceptions
):
add_failure(
failures_by_path,
path_key,
"remove the stale optional-dependency exception for "
f"`{dependency_entry_label(section_name, dependency_name)}` from "
"`OPTIONAL_DEPENDENCY_EXCEPTIONS`",
)
for path_key, section_name, dependency_name in sorted(
set(INTERNAL_DEPENDENCY_FEATURE_EXCEPTIONS)
- used_internal_dependency_feature_exceptions
):
add_failure(
failures_by_path,
path_key,
"remove the stale internal dependency feature exception for "
f"`{dependency_entry_label(section_name, dependency_name)}` from "
"`INTERNAL_DEPENDENCY_FEATURE_EXCEPTIONS`",
)
def add_failure(failures_by_path: dict[str, list[str]], path_key: str, error: str) -> None:
failures_by_path.setdefault(path_key, []).append(error)
def workspace_package_names() -> set[str]:
package_names = set()
for path in cargo_manifests():
manifest = load_manifest(path)
package = manifest.get("package")
if not isinstance(package, dict):
continue
package_name = package.get("name")
if isinstance(package_name, str):
package_names.add(package_name)
return package_names
def load_manifest(path: Path) -> dict:
return tomllib.loads(path.read_text())
@@ -121,5 +412,9 @@ def cargo_manifests() -> list[Path]:
)
def manifests_to_verify() -> list[Path]:
return [CARGO_RS_ROOT / "Cargo.toml", *cargo_manifests()]
if __name__ == "__main__":
sys.exit(main())

View File

@@ -15,7 +15,8 @@ In the codex-rs folder where the rust code lives:
- When you cannot make that API change and still need a small positional-literal callsite in Rust, follow the `argument_comment_lint` convention:
- Use an exact `/*param_name*/` comment before opaque literal arguments such as `None`, booleans, and numeric literals when passing them by position.
- Do not add these comments for string or char literals unless the comment adds real clarity; those literals are intentionally exempt from the lint.
- If you add one of these comments, the parameter name must exactly match the callee signature.
- The parameter name in the comment must exactly match the callee signature.
- You can run `just argument-comment-lint` to run the lint check locally. This is powered by Bazel, so running it the first time can be slow if Bazel is not warmed up, though incremental invocations should take <15s. Most of the time, it is best to update the PR and let CI take responsibility for checking this (or run it asynchronously in the background after submitting the PR). Note CI checks all three platforms, which the local run does not.
- When possible, make `match` statements exhaustive and avoid wildcard arms.
- Newly added traits should include doc comments that explain their role and how implementations are expected to use them.
- When writing tests, prefer comparing the equality of entire objects over fields one by one.
@@ -50,8 +51,6 @@ Run `just fmt` (in `codex-rs` directory) automatically after you have finished m
Before finalizing a large change to `codex-rs`, run `just fix -p <project>` (in `codex-rs` directory) to fix any linter issues in the code. Prefer scoping with `-p` to avoid slow workspacewide Clippy builds; only run `just fix` without `-p` if you changed shared crates. Do not re-run tests after running `fix` or `fmt`.
Also run `just argument-comment-lint` to ensure the codebase is clean of comment lint errors.
## The `codex-core` crate
Over time, the `codex-core` crate (defined in `codex-rs/core/`) has become bloated because it is the largest crate, so it is often easier to add something new to `codex-core` rather than refactor out the library code you need so your new code neither takes a dependency on, nor contributes to the size of, `codex-core`.

View File

@@ -366,6 +366,30 @@ async fn test_fuzzy_file_search_session_streams_updates() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_fuzzy_file_search_session_update_is_case_insensitive() -> Result<()> {
let codex_home = TempDir::new()?;
let root = TempDir::new()?;
std::fs::write(root.path().join("alpha.txt"), "contents")?;
let mut mcp = initialized_mcp(&codex_home).await?;
let root_path = root.path().to_string_lossy().to_string();
let session_id = "session-case-insensitive";
mcp.start_fuzzy_file_search_session(session_id, vec![root_path.clone()])
.await?;
mcp.update_fuzzy_file_search_session(session_id, "ALP")
.await?;
let payload =
wait_for_session_updated(&mut mcp, session_id, "ALP", FileExpectation::NonEmpty).await?;
assert_eq!(payload.files.len(), 1);
assert_eq!(payload.files[0].root, root_path);
assert_eq!(payload.files[0].path, "alpha.txt");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_fuzzy_file_search_session_no_updates_after_complete_until_query_edited() -> Result<()>
{

View File

@@ -314,7 +314,6 @@ pub fn build_exec_request(
pub(crate) async fn execute_exec_request(
exec_request: ExecRequest,
sandbox_policy: &SandboxPolicy,
stdout_stream: Option<StdoutStream>,
after_spawn: Option<Box<dyn FnOnce() + Send>>,
) -> Result<ExecToolCallOutput> {
@@ -328,13 +327,12 @@ pub(crate) async fn execute_exec_request(
sandbox,
windows_sandbox_level,
windows_sandbox_private_desktop,
sandbox_policy: _sandbox_policy_from_env,
sandbox_policy,
file_system_sandbox_policy,
network_sandbox_policy,
windows_restricted_token_filesystem_overlay,
arg0,
} = exec_request;
let _ = _sandbox_policy_from_env;
let params = ExecParams {
command,
@@ -354,7 +352,7 @@ pub(crate) async fn execute_exec_request(
let raw_output_result = exec(
params,
sandbox,
sandbox_policy,
&sandbox_policy,
&file_system_sandbox_policy,
windows_restricted_token_filesystem_overlay.as_ref(),
network_sandbox_policy,

View File

@@ -141,14 +141,7 @@ pub async fn execute_env(
exec_request: ExecRequest,
stdout_stream: Option<StdoutStream>,
) -> crate::error::Result<ExecToolCallOutput> {
let effective_policy = exec_request.sandbox_policy.clone();
execute_exec_request(
exec_request,
&effective_policy,
stdout_stream,
/*after_spawn*/ None,
)
.await
execute_exec_request(exec_request, stdout_stream, /*after_spawn*/ None).await
}
pub async fn execute_exec_request_with_after_spawn(
@@ -156,6 +149,5 @@ pub async fn execute_exec_request_with_after_spawn(
stdout_stream: Option<StdoutStream>,
after_spawn: Option<Box<dyn FnOnce() + Send>>,
) -> crate::error::Result<ExecToolCallOutput> {
let effective_policy = exec_request.sandbox_policy.clone();
execute_exec_request(exec_request, &effective_policy, stdout_stream, after_spawn).await
execute_exec_request(exec_request, stdout_stream, after_spawn).await
}

View File

@@ -185,14 +185,9 @@ pub(crate) async fn execute_user_shell_command(
tx_event: session.get_tx_event(),
});
let exec_result = execute_exec_request(
exec_env,
&sandbox_policy,
stdout_stream,
/*after_spawn*/ None,
)
.or_cancel(&cancellation_token)
.await;
let exec_result = execute_exec_request(exec_env, stdout_stream, /*after_spawn*/ None)
.or_cancel(&cancellation_token)
.await;
match exec_result {
Err(CancelErr::Cancelled) => {

View File

@@ -347,7 +347,7 @@ async fn multi_agent_v2_spawn_requires_task_name() {
Arc::new(turn),
"spawn_agent",
function_payload(json!({
"items": [{"type": "text", "text": "inspect this repo"}]
"message": "inspect this repo"
})),
);
let Err(err) = SpawnAgentHandlerV2.handle(invocation).await else {
@@ -360,7 +360,7 @@ async fn multi_agent_v2_spawn_requires_task_name() {
}
#[tokio::test]
async fn multi_agent_v2_spawn_rejects_legacy_message_field() {
async fn multi_agent_v2_spawn_rejects_legacy_items_field() {
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
let root = manager
@@ -387,12 +387,12 @@ async fn multi_agent_v2_spawn_rejects_legacy_message_field() {
})),
);
let Err(err) = SpawnAgentHandlerV2.handle(invocation).await else {
panic!("legacy message field should be rejected");
panic!("legacy items field should be rejected");
};
let FunctionCallError::RespondToModel(message) = err else {
panic!("legacy message field should surface as a model-facing error");
panic!("legacy items field should surface as a model-facing error");
};
assert!(message.contains("unknown field `message`"));
assert!(message.contains("unknown field `items`"));
}
#[tokio::test]
@@ -444,7 +444,7 @@ async fn multi_agent_v2_spawn_returns_path_and_send_message_accepts_relative_pat
turn.clone(),
"spawn_agent",
function_payload(json!({
"items": [{"type": "text", "text": "inspect this repo"}],
"message": "inspect this repo",
"task_name": "test_process"
})),
))
@@ -496,7 +496,7 @@ async fn multi_agent_v2_spawn_returns_path_and_send_message_accepts_relative_pat
"send_message",
function_payload(json!({
"target": "test_process",
"items": [{"type": "text", "text": "continue"}]
"message": "continue"
})),
))
.await
@@ -539,7 +539,7 @@ async fn multi_agent_v2_spawn_rejects_legacy_fork_context() {
Arc::new(turn),
"spawn_agent",
function_payload(json!({
"items": [{"type": "text", "text": "inspect this repo"}],
"message": "inspect this repo",
"task_name": "worker",
"fork_context": true
})),
@@ -578,7 +578,7 @@ async fn multi_agent_v2_spawn_rejects_invalid_fork_turns_string() {
Arc::new(turn),
"spawn_agent",
function_payload(json!({
"items": [{"type": "text", "text": "inspect this repo"}],
"message": "inspect this repo",
"task_name": "worker",
"fork_turns": "banana"
})),
@@ -617,7 +617,7 @@ async fn multi_agent_v2_spawn_rejects_zero_fork_turns() {
Arc::new(turn),
"spawn_agent",
function_payload(json!({
"items": [{"type": "text", "text": "inspect this repo"}],
"message": "inspect this repo",
"task_name": "worker",
"fork_turns": "0"
})),
@@ -689,7 +689,7 @@ async fn multi_agent_v2_send_message_accepts_root_target_from_child() {
"send_message",
function_payload(json!({
"target": "/root",
"items": [{"type": "text", "text": "done"}]
"message": "done"
})),
))
.await
@@ -709,6 +709,86 @@ async fn multi_agent_v2_send_message_accepts_root_target_from_child() {
}));
}
#[tokio::test]
async fn multi_agent_v2_assign_task_rejects_root_target_from_child() {
let (mut session, mut 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 mut config = (*turn.config).clone();
config
.features
.enable(Feature::MultiAgentV2)
.expect("test config should allow feature update");
turn.config = Arc::new(config);
let child_path = AgentPath::try_from("/root/worker").expect("agent path");
let child_thread_id = session
.services
.agent_control
.spawn_agent_with_metadata(
(*turn.config).clone(),
vec![UserInput::Text {
text: "inspect this repo".to_string(),
text_elements: Vec::new(),
}]
.into(),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: root.thread_id,
depth: 1,
agent_path: Some(child_path.clone()),
agent_nickname: None,
agent_role: None,
})),
crate::agent::control::SpawnAgentOptions::default(),
)
.await
.expect("worker spawn should succeed")
.thread_id;
session.conversation_id = child_thread_id;
turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: root.thread_id,
depth: 1,
agent_path: Some(child_path),
agent_nickname: None,
agent_role: None,
});
let err = AssignTaskHandlerV2
.handle(invocation(
Arc::new(session),
Arc::new(turn),
"assign_task",
function_payload(json!({
"target": "/root",
"message": "run this",
"interrupt": true
})),
))
.await
.expect_err("assign_task should reject the root target");
assert_eq!(
err,
FunctionCallError::RespondToModel("Tasks can't be assigned to the root agent".to_string())
);
let root_ops = manager
.captured_ops()
.into_iter()
.filter_map(|(id, op)| (id == root.thread_id).then_some(op))
.collect::<Vec<_>>();
assert!(!root_ops.iter().any(|op| matches!(op, Op::Interrupt)));
assert!(
!root_ops
.iter()
.any(|op| matches!(op, Op::InterAgentCommunication { .. }))
);
}
#[tokio::test]
async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_message() {
let (mut session, mut turn) = make_session_and_context().await;
@@ -731,7 +811,7 @@ async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_messa
turn.clone(),
"spawn_agent",
function_payload(json!({
"items": [{"type": "text", "text": "inspect this repo"}],
"message": "inspect this repo",
"task_name": "worker"
})),
))
@@ -909,7 +989,7 @@ async fn multi_agent_v2_list_agents_omits_closed_agents() {
turn.clone(),
"spawn_agent",
function_payload(json!({
"items": [{"type": "text", "text": "inspect this repo"}],
"message": "inspect this repo",
"task_name": "worker"
})),
))
@@ -952,7 +1032,7 @@ async fn multi_agent_v2_list_agents_omits_closed_agents() {
}
#[tokio::test]
async fn multi_agent_v2_send_message_rejects_structured_items() {
async fn multi_agent_v2_send_message_rejects_legacy_items_field() {
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
let root = manager
@@ -973,7 +1053,7 @@ async fn multi_agent_v2_send_message_rejects_structured_items() {
turn.clone(),
"spawn_agent",
function_payload(json!({
"items": [{"type": "text", "text": "boot worker"}],
"message": "boot worker",
"task_name": "worker"
})),
))
@@ -999,14 +1079,12 @@ async fn multi_agent_v2_send_message_rejects_structured_items() {
);
let Err(err) = SendMessageHandlerV2.handle(invocation).await else {
panic!("structured items should be rejected in v2");
panic!("legacy items field should be rejected in v2");
};
assert_eq!(
err,
FunctionCallError::RespondToModel(
"send_message only supports text content in MultiAgentV2 for now".to_string()
)
);
let FunctionCallError::RespondToModel(message) = err else {
panic!("legacy items field should surface as a model-facing error");
};
assert!(message.contains("unknown field `items`"));
}
#[tokio::test]
@@ -1031,7 +1109,7 @@ async fn multi_agent_v2_send_message_rejects_interrupt_parameter() {
turn.clone(),
"spawn_agent",
function_payload(json!({
"items": [{"type": "text", "text": "boot worker"}],
"message": "boot worker",
"task_name": "worker"
})),
))
@@ -1050,7 +1128,7 @@ async fn multi_agent_v2_send_message_rejects_interrupt_parameter() {
"send_message",
function_payload(json!({
"target": agent_id.to_string(),
"items": [{"type": "text", "text": "continue"}],
"message": "continue",
"interrupt": true
})),
);
@@ -1062,7 +1140,7 @@ async fn multi_agent_v2_send_message_rejects_interrupt_parameter() {
panic!("expected model-facing parse error");
};
assert!(message.starts_with(
"failed to parse function arguments: unknown field `interrupt`, expected `target` or `items`"
"failed to parse function arguments: unknown field `interrupt`, expected `target` or `message`"
));
let ops = manager.captured_ops();
@@ -1104,7 +1182,7 @@ async fn multi_agent_v2_assign_task_interrupts_busy_child_without_losing_message
turn.clone(),
"spawn_agent",
function_payload(json!({
"items": [{"type": "text", "text": "boot worker"}],
"message": "boot worker",
"task_name": "worker"
})),
))
@@ -1142,7 +1220,7 @@ async fn multi_agent_v2_assign_task_interrupts_busy_child_without_losing_message
"assign_task",
function_payload(json!({
"target": agent_id.to_string(),
"items": [{"type": "text", "text": "continue"}],
"message": "continue",
"interrupt": true
})),
))
@@ -1233,7 +1311,7 @@ async fn multi_agent_v2_assign_task_completion_notifies_parent_on_every_turn() {
turn.clone(),
"spawn_agent",
function_payload(json!({
"items": [{"type": "text", "text": "boot worker"}],
"message": "boot worker",
"task_name": "worker"
})),
))
@@ -1271,7 +1349,7 @@ async fn multi_agent_v2_assign_task_completion_notifies_parent_on_every_turn() {
"assign_task",
function_payload(json!({
"target": agent_id.to_string(),
"items": [{"type": "text", "text": "continue"}],
"message": "continue",
})),
))
.await
@@ -1340,6 +1418,59 @@ async fn multi_agent_v2_assign_task_completion_notifies_parent_on_every_turn() {
assert_eq!(notifications.len(), 2);
}
#[tokio::test]
async fn multi_agent_v2_assign_task_rejects_legacy_items_field() {
let (mut session, mut 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 mut config = turn.config.as_ref().clone();
let _ = config.features.enable(Feature::MultiAgentV2);
turn.config = Arc::new(config);
let session = Arc::new(session);
let turn = Arc::new(turn);
SpawnAgentHandlerV2
.handle(invocation(
session.clone(),
turn.clone(),
"spawn_agent",
function_payload(json!({
"message": "boot worker",
"task_name": "worker"
})),
))
.await
.expect("spawn worker");
let agent_id = session
.services
.agent_control
.resolve_agent_reference(session.conversation_id, &turn.session_source, "worker")
.await
.expect("worker should resolve");
let invocation = invocation(
session,
turn,
"assign_task",
function_payload(json!({
"target": agent_id.to_string(),
"items": [{"type": "text", "text": "continue"}],
})),
);
let Err(err) = AssignTaskHandlerV2.handle(invocation).await else {
panic!("legacy items field should be rejected in v2");
};
let FunctionCallError::RespondToModel(message) = err else {
panic!("legacy items field should surface as a model-facing error");
};
assert!(message.contains("unknown field `items`"));
}
#[tokio::test]
async fn multi_agent_v2_interrupted_turn_does_not_notify_parent() {
let (mut session, mut turn) = make_session_and_context().await;
@@ -1362,7 +1493,7 @@ async fn multi_agent_v2_interrupted_turn_does_not_notify_parent() {
turn.clone(),
"spawn_agent",
function_payload(json!({
"items": [{"type": "text", "text": "boot worker"}],
"message": "boot worker",
"task_name": "worker"
})),
))
@@ -1438,7 +1569,7 @@ async fn multi_agent_v2_spawn_includes_agent_id_key_when_named() {
Arc::new(turn),
"spawn_agent",
function_payload(json!({
"items": [{"type": "text", "text": "inspect this repo"}],
"message": "inspect this repo",
"task_name": "test_process"
})),
))
@@ -1476,7 +1607,7 @@ async fn multi_agent_v2_spawn_surfaces_task_name_validation_errors() {
Arc::new(turn),
"spawn_agent",
function_payload(json!({
"items": [{"type": "text", "text": "inspect this repo"}],
"message": "inspect this repo",
"task_name": "BadName"
})),
);
@@ -2103,7 +2234,7 @@ async fn multi_agent_v2_wait_agent_accepts_timeout_only_argument() {
turn.clone(),
"spawn_agent",
function_payload(json!({
"items": [{"type": "text", "text": "boot worker"}],
"message": "boot worker",
"task_name": "worker"
})),
))
@@ -2349,7 +2480,7 @@ async fn multi_agent_v2_wait_agent_returns_summary_for_mailbox_activity() {
turn.clone(),
"spawn_agent",
function_payload(json!({
"items": [{"type": "text", "text": "inspect this repo"}],
"message": "inspect this repo",
"task_name": "test_process"
})),
))
@@ -2440,7 +2571,7 @@ async fn multi_agent_v2_wait_agent_waits_for_new_mail_after_start() {
turn.clone(),
"spawn_agent",
function_payload(json!({
"items": [{"type": "text", "text": "boot worker"}],
"message": "boot worker",
"task_name": "worker"
})),
))
@@ -2540,7 +2671,7 @@ async fn multi_agent_v2_wait_agent_wakes_on_any_mailbox_notification() {
turn.clone(),
"spawn_agent",
function_payload(json!({
"items": [{"type": "text", "text": format!("boot {task_name}")}],
"message": format!("boot {task_name}"),
"task_name": task_name
})),
))
@@ -2627,7 +2758,7 @@ async fn multi_agent_v2_wait_agent_does_not_return_completed_content() {
turn.clone(),
"spawn_agent",
function_payload(json!({
"items": [{"type": "text", "text": "boot worker"}],
"message": "boot worker",
"task_name": "worker"
})),
))
@@ -2713,7 +2844,7 @@ async fn multi_agent_v2_close_agent_accepts_task_name_target() {
turn.clone(),
"spawn_agent",
function_payload(json!({
"items": [{"type": "text", "text": "inspect this repo"}],
"message": "inspect this repo",
"task_name": "worker"
})),
))

View File

@@ -1,7 +1,7 @@
use super::message_tool::AssignTaskArgs;
use super::message_tool::MessageDeliveryMode;
use super::message_tool::MessageToolResult;
use super::message_tool::handle_message_tool;
use super::message_tool::handle_message_string_tool;
use super::*;
pub(crate) struct Handler;
@@ -21,11 +21,11 @@ impl ToolHandler for Handler {
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
let arguments = function_arguments(invocation.payload.clone())?;
let args: AssignTaskArgs = parse_arguments(&arguments)?;
handle_message_tool(
handle_message_string_tool(
invocation,
MessageDeliveryMode::TriggerTurn,
args.target,
args.items,
args.message,
args.interrupt,
)
.await

View File

@@ -106,6 +106,7 @@ impl ToolHandler for Handler {
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct CloseAgentArgs {
target: String,
}

View File

@@ -40,6 +40,7 @@ impl ToolHandler for Handler {
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct ListAgentsArgs {
path_prefix: Option<String>,
}

View File

@@ -1,27 +1,18 @@
//! Shared argument parsing and dispatch for the v2 text-only agent messaging tools.
//!
//! `send_message` and `assign_task` intentionally expose the same input shape and differ only in
//! whether the resulting `InterAgentCommunication` should wake the target immediately.
//! `send_message` and `assign_task` share the same submission path and differ only in whether the
//! resulting `InterAgentCommunication` should wake the target immediately.
use super::*;
use crate::agent::control::render_input_preview;
use codex_protocol::protocol::InterAgentCommunication;
#[derive(Clone, Copy)]
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum MessageDeliveryMode {
QueueOnly,
TriggerTurn,
}
impl MessageDeliveryMode {
/// Returns the model-visible error message for non-text inputs.
fn unsupported_items_error(self) -> &'static str {
match self {
Self::QueueOnly => "send_message only supports text content in MultiAgentV2 for now",
Self::TriggerTurn => "assign_task only supports text content in MultiAgentV2 for now",
}
}
/// Returns whether the produced communication should start a turn immediately.
fn apply(self, communication: InterAgentCommunication) -> InterAgentCommunication {
match self {
@@ -42,7 +33,7 @@ impl MessageDeliveryMode {
/// Input for the MultiAgentV2 `send_message` tool.
pub(crate) struct SendMessageArgs {
pub(crate) target: String,
pub(crate) items: Vec<UserInput>,
pub(crate) message: String,
}
#[derive(Debug, Deserialize)]
@@ -50,7 +41,7 @@ pub(crate) struct SendMessageArgs {
/// Input for the MultiAgentV2 `assign_task` tool.
pub(crate) struct AssignTaskArgs {
pub(crate) target: String,
pub(crate) items: Vec<UserInput>,
pub(crate) message: String,
#[serde(default)]
pub(crate) interrupt: bool,
}
@@ -79,33 +70,38 @@ impl ToolOutput for MessageToolResult {
}
}
/// Validates that the tool input is non-empty text-only content and returns its preview string.
fn text_content(
items: &[UserInput],
mode: MessageDeliveryMode,
) -> Result<String, FunctionCallError> {
if items.is_empty() {
fn message_content(message: String) -> Result<String, FunctionCallError> {
if message.trim().is_empty() {
return Err(FunctionCallError::RespondToModel(
"Items can't be empty".to_string(),
"Empty message can't be sent to an agent".to_string(),
));
}
if items
.iter()
.all(|item| matches!(item, UserInput::Text { .. }))
{
return Ok(render_input_preview(&(items.to_vec().into())));
}
Err(FunctionCallError::RespondToModel(
mode.unsupported_items_error().to_string(),
))
Ok(message)
}
/// Handles the shared MultiAgentV2 text-message flow for both `send_message` and `assign_task`.
pub(crate) async fn handle_message_tool(
/// Handles the shared MultiAgentV2 plain-text message flow for both `send_message` and `assign_task`.
pub(crate) async fn handle_message_string_tool(
invocation: ToolInvocation,
mode: MessageDeliveryMode,
target: String,
items: Vec<UserInput>,
message: String,
interrupt: bool,
) -> Result<MessageToolResult, FunctionCallError> {
handle_message_submission(
invocation,
mode,
target,
message_content(message)?,
interrupt,
)
.await
}
async fn handle_message_submission(
invocation: ToolInvocation,
mode: MessageDeliveryMode,
target: String,
prompt: String,
interrupt: bool,
) -> Result<MessageToolResult, FunctionCallError> {
let ToolInvocation {
@@ -117,12 +113,21 @@ pub(crate) async fn handle_message_tool(
} = invocation;
let _ = payload;
let receiver_thread_id = resolve_agent_target(&session, &turn, &target).await?;
let prompt = text_content(&items, mode)?;
let receiver_agent = session
.services
.agent_control
.get_agent_metadata(receiver_thread_id)
.unwrap_or_default();
if mode == MessageDeliveryMode::TriggerTurn
&& receiver_agent
.agent_path
.as_ref()
.is_some_and(AgentPath::is_root)
{
return Err(FunctionCallError::RespondToModel(
"Tasks can't be assigned to the root agent".to_string(),
));
}
if interrupt {
session
.services

View File

@@ -1,7 +1,7 @@
use super::message_tool::MessageDeliveryMode;
use super::message_tool::MessageToolResult;
use super::message_tool::SendMessageArgs;
use super::message_tool::handle_message_tool;
use super::message_tool::handle_message_string_tool;
use super::*;
pub(crate) struct Handler;
@@ -21,11 +21,11 @@ impl ToolHandler for Handler {
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
let arguments = function_arguments(invocation.payload.clone())?;
let args: SendMessageArgs = parse_arguments(&arguments)?;
handle_message_tool(
handle_message_string_tool(
invocation,
MessageDeliveryMode::QueueOnly,
args.target,
args.items,
args.message,
/*interrupt*/ false,
)
.await

View File

@@ -40,7 +40,7 @@ impl ToolHandler for Handler {
.map(str::trim)
.filter(|role| !role.is_empty());
let initial_operation = parse_collab_input(/*message*/ None, Some(args.items))?;
let initial_operation = parse_collab_input(Some(args.message), /*items*/ None)?;
let prompt = render_input_preview(&initial_operation);
let session_source = turn.session_source.clone();
@@ -202,7 +202,7 @@ impl ToolHandler for Handler {
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct SpawnAgentArgs {
items: Vec<UserInput>,
message: String,
task_name: String,
agent_type: Option<String>,
model: Option<String>,

View File

@@ -75,6 +75,7 @@ impl ToolHandler for Handler {
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct WaitArgs {
timeout_ms: Option<i64>,
}

View File

@@ -497,13 +497,13 @@ fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() {
panic!("spawn_agent should use object params");
};
assert!(properties.contains_key("task_name"));
assert!(properties.contains_key("items"));
assert!(properties.contains_key("message"));
assert!(properties.contains_key("fork_turns"));
assert!(!properties.contains_key("message"));
assert!(!properties.contains_key("items"));
assert!(!properties.contains_key("fork_context"));
assert_eq!(
required.as_ref(),
Some(&vec!["task_name".to_string(), "items".to_string()])
Some(&vec!["task_name".to_string(), "message".to_string()])
);
let output_schema = output_schema
.as_ref()
@@ -527,10 +527,11 @@ fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() {
};
assert!(properties.contains_key("target"));
assert!(!properties.contains_key("interrupt"));
assert!(!properties.contains_key("message"));
assert!(properties.contains_key("message"));
assert!(!properties.contains_key("items"));
assert_eq!(
required.as_ref(),
Some(&vec!["target".to_string(), "items".to_string()])
Some(&vec!["target".to_string(), "message".to_string()])
);
let assign_task = find_tool(&tools, "assign_task");
@@ -546,10 +547,11 @@ fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() {
panic!("assign_task should use object params");
};
assert!(properties.contains_key("target"));
assert!(!properties.contains_key("message"));
assert!(properties.contains_key("message"));
assert!(!properties.contains_key("items"));
assert_eq!(
required.as_ref(),
Some(&vec!["target".to_string(), "items".to_string()])
Some(&vec!["target".to_string(), "message".to_string()])
);
let wait_agent = find_tool(&tools, "wait_agent");

View File

@@ -103,18 +103,14 @@ fn decode_body_bytes(body: &[u8], content_encoding: Option<&str>) -> Vec<u8> {
impl ResponsesRequest {
pub fn body_json(&self) -> Value {
let body = self.decoded_body_bytes();
serde_json::from_slice(&body).unwrap()
}
pub fn decoded_body_bytes(&self) -> Vec<u8> {
decode_body_bytes(
let body = decode_body_bytes(
&self.0.body,
self.0
.headers
.get("content-encoding")
.and_then(|value| value.to_str().ok()),
)
);
serde_json::from_slice(&body).unwrap()
}
pub fn body_bytes(&self) -> Vec<u8> {

View File

@@ -18,7 +18,6 @@ use core_test_support::skip_if_no_network;
use core_test_support::test_codex::TestCodex;
use core_test_support::test_codex::test_codex;
use pretty_assertions::assert_eq;
use serde_json::Value;
use serde_json::json;
use std::time::Duration;
use tokio::time::Instant;
@@ -64,108 +63,6 @@ fn has_subagent_notification(req: &ResponsesRequest) -> bool {
.any(|text| text.contains("<subagent_notification>"))
}
fn cache_prefix_request_body(request: &ResponsesRequest, call_id: &str) -> Result<Value> {
let mut body = request.body_json();
let object = body
.as_object_mut()
.ok_or_else(|| anyhow::anyhow!("expected JSON object request body, got {body:?}"))?;
let input = object
.get_mut("input")
.and_then(Value::as_array_mut)
.ok_or_else(|| anyhow::anyhow!("expected request input array, got {object:?}"))?;
let spawn_call_index = input
.iter()
.rposition(|item| {
item.get("type").and_then(Value::as_str) == Some("function_call")
&& item.get("call_id").and_then(Value::as_str) == Some(call_id)
})
.ok_or_else(|| {
anyhow::anyhow!("expected request input to include function_call {call_id}: {input:?}")
})?;
// The cache-preservation contract is only for the shared request prefix up to
// and including the forked `spawn_agent` call. The `FunctionCallOutput` for that
// call is the first legal divergence point between parent and child requests,
// so truncate immediately before it.
input.truncate(spawn_call_index + 1);
if let Some(tools) = object.get_mut("tools") {
*tools = normalize_tools_for_cache_prefix(tools);
}
Ok(body)
}
fn prompt_cache_key(request: &ResponsesRequest) -> Option<String> {
request
.body_json()
.get("prompt_cache_key")
.and_then(Value::as_str)
.map(str::to_string)
}
fn normalize_tools_for_cache_prefix(tools: &Value) -> Value {
let normalized_tools = tools
.as_array()
.unwrap_or_else(|| panic!("expected tools array: {tools:?}"))
.iter()
.filter_map(normalize_tool_for_cache_prefix)
.collect::<Vec<_>>();
Value::Array(normalized_tools)
}
fn normalize_tool_for_cache_prefix(tool: &Value) -> Option<Value> {
let mut normalized = tool
.as_object()
.unwrap_or_else(|| panic!("expected tool object: {tool:?}"))
.clone();
if normalized.get("type").and_then(Value::as_str) == Some("namespace")
&& let Some(namespace_tools) = normalized.get("tools")
{
normalized.insert(
"tools".to_string(),
normalize_namespace_tools_for_cache_prefix(namespace_tools),
);
}
if normalized
.get("defer_loading")
.and_then(Value::as_bool)
.unwrap_or(false)
&& normalized.get("type").and_then(Value::as_str) == Some("function")
{
normalized.remove("parameters");
}
Some(Value::Object(normalized))
}
fn normalize_namespace_tools_for_cache_prefix(tools: &Value) -> Value {
let normalized_tools = tools
.as_array()
.unwrap_or_else(|| panic!("expected namespace tools array: {tools:?}"))
.iter()
.filter_map(|tool| {
let tool_object = tool
.as_object()
.unwrap_or_else(|| panic!("expected namespace tool object: {tool:?}"))
.clone();
if tool_object
.get("defer_loading")
.and_then(Value::as_bool)
.unwrap_or(false)
&& tool_object.get("type").and_then(Value::as_str) == Some("function")
{
None
} else {
normalize_tool_for_cache_prefix(&Value::Object(tool_object))
}
})
.collect::<Vec<_>>();
Value::Array(normalized_tools)
}
fn tool_parameter_description(
req: &ResponsesRequest,
tool_name: &str,
@@ -431,12 +328,9 @@ async fn spawned_child_receives_forked_parent_context() -> Result<()> {
)
.await;
let child_request_log = mount_sse_once_match(
let _child_request_log = mount_sse_once_match(
&server,
|req: &wiremock::Request| {
body_contains(req, CHILD_PROMPT)
&& body_contains(req, FORKED_SPAWN_AGENT_OUTPUT_MESSAGE)
},
|req: &wiremock::Request| body_contains(req, CHILD_PROMPT),
sse(vec![
ev_response_created("resp-child-1"),
ev_assistant_message("msg-child-1", "child done"),
@@ -445,11 +339,9 @@ async fn spawned_child_receives_forked_parent_context() -> Result<()> {
)
.await;
let turn1_followup = mount_sse_once_match(
let _turn1_followup = mount_sse_once_match(
&server,
|req: &wiremock::Request| {
body_contains(req, SPAWN_CALL_ID) && !body_contains(req, CHILD_PROMPT)
},
|req: &wiremock::Request| body_contains(req, SPAWN_CALL_ID),
sse(vec![
ev_response_created("resp-turn1-2"),
ev_assistant_message("msg-turn1-2", "parent done"),
@@ -471,17 +363,18 @@ async fn spawned_child_receives_forked_parent_context() -> Result<()> {
test.submit_turn(TURN_1_PROMPT).await?;
let _ = spawn_turn.single_request();
let parent_followup_requests = wait_for_requests(&turn1_followup).await?;
let parent_followup_request = parent_followup_requests
.first()
.expect("parent follow-up request should be captured");
let deadline = Instant::now() + Duration::from_secs(2);
let child_request = loop {
if let Some(request) = child_request_log
.requests()
if let Some(request) = server
.received_requests()
.await
.unwrap_or_default()
.into_iter()
.find(|request| request.body_contains_text(CHILD_PROMPT))
.find(|request| {
body_contains(request, CHILD_PROMPT)
&& body_contains(request, FORKED_SPAWN_AGENT_OUTPUT_MESSAGE)
})
{
break request;
}
@@ -490,19 +383,31 @@ async fn spawned_child_receives_forked_parent_context() -> Result<()> {
}
sleep(Duration::from_millis(10)).await;
};
assert!(child_request.body_contains_text(TURN_0_FORK_PROMPT));
assert!(child_request.body_contains_text("seeded"));
let parent_cache_prefix = cache_prefix_request_body(parent_followup_request, SPAWN_CALL_ID)?;
let child_cache_prefix = cache_prefix_request_body(&child_request, SPAWN_CALL_ID)?;
assert_eq!(
prompt_cache_key(parent_followup_request),
prompt_cache_key(&child_request),
"forked parent and child requests must reuse the same prompt_cache_key so backend sharding can colocate them for KV cache reuse"
);
assert_eq!(
parent_cache_prefix, child_cache_prefix,
"forked child requests must preserve every cache-relevant request field and the conversation-item prefix exactly through the shared spawn_agent call; namespace shells and non-deferred tools must stay stable, while deferred namespace members may only appear after tool_search_output"
);
assert!(body_contains(&child_request, TURN_0_FORK_PROMPT));
assert!(body_contains(&child_request, "seeded"));
let child_body = child_request
.body_json::<serde_json::Value>()
.expect("forked child request body should be json");
let function_call_output = child_body["input"]
.as_array()
.and_then(|items| {
items.iter().find(|item| {
item["type"].as_str() == Some("function_call_output")
&& item["call_id"].as_str() == Some(SPAWN_CALL_ID)
})
})
.unwrap_or_else(|| panic!("expected forked child request to include spawn_agent output"));
let (content, success) = match &function_call_output["output"] {
serde_json::Value::String(text) => (Some(text.as_str()), None),
serde_json::Value::Object(output) => (
output.get("content").and_then(serde_json::Value::as_str),
output.get("success").and_then(serde_json::Value::as_bool),
),
_ => (None, None),
};
assert_eq!(content, Some(FORKED_SPAWN_AGENT_OUTPUT_MESSAGE));
assert_ne!(success, Some(false));
Ok(())
}

View File

@@ -336,7 +336,7 @@ where
fn create_pattern(pattern: &str) -> Pattern {
Pattern::new(
pattern,
CaseMatching::Smart,
CaseMatching::Ignore,
Normalization::Smart,
AtomKind::Fuzzy,
)
@@ -508,7 +508,7 @@ fn matcher_worker(
nucleo.pattern.reparse(
0,
&query,
CaseMatching::Smart,
CaseMatching::Ignore,
Normalization::Smart,
append,
);

View File

@@ -0,0 +1,3 @@
PRAGMA auto_vacuum = INCREMENTAL;
DROP TABLE IF EXISTS logs;

View File

@@ -147,12 +147,28 @@ fn base_sqlite_options(path: &Path) -> SqliteConnectOptions {
}
async fn open_state_sqlite(path: &Path, migrator: &'static Migrator) -> anyhow::Result<SqlitePool> {
let options = base_sqlite_options(path);
let options = base_sqlite_options(path).auto_vacuum(SqliteAutoVacuum::Incremental);
let pool = SqlitePoolOptions::new()
.max_connections(5)
.connect_with(options)
.await?;
migrator.run(&pool).await?;
let auto_vacuum = sqlx::query_scalar::<_, i64>("PRAGMA auto_vacuum")
.fetch_one(&pool)
.await?;
if auto_vacuum != SqliteAutoVacuum::Incremental as i64 {
// Existing state DBs need one non-transactional `VACUUM` before
// SQLite persists `auto_vacuum = INCREMENTAL` in the database header.
sqlx::query("PRAGMA auto_vacuum = INCREMENTAL")
.execute(&pool)
.await?;
// We do it on best effort. If the lock can't be acquired, it will be done at next run.
let _ = sqlx::query("VACUUM").execute(&pool).await;
}
// We do it on best effort. If the lock can't be acquired, it will be done at next run.
let _ = sqlx::query("PRAGMA incremental_vacuum")
.execute(&pool)
.await;
Ok(pool)
}

View File

@@ -537,7 +537,6 @@ mod tests {
use crate::LogQuery;
use crate::logs_db_path;
use crate::migrations::LOGS_MIGRATOR;
use crate::state_db_path;
use chrono::Utc;
use pretty_assertions::assert_eq;
use sqlx::SqlitePool;
@@ -590,10 +589,8 @@ mod tests {
.await
.expect("insert test logs");
let state_count = log_row_count(state_db_path(codex_home.as_path()).as_path()).await;
let logs_count = log_row_count(logs_db_path(codex_home.as_path()).as_path()).await;
assert_eq!(state_count, 0);
assert_eq!(logs_count, 1);
let _ = tokio::fs::remove_dir_all(codex_home).await;

View File

@@ -66,7 +66,7 @@ pub fn create_spawn_agent_tool_v2(options: SpawnAgentToolOptions<'_>) -> ToolSpe
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["task_name".to_string(), "items".to_string()]),
required: Some(vec!["task_name".to_string(), "message".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: Some(spawn_agent_output_schema_v2()),
@@ -127,7 +127,12 @@ pub fn create_send_message_tool() -> ToolSpec {
),
},
),
("items".to_string(), create_collab_input_items_schema()),
(
"message".to_string(),
JsonSchema::String {
description: Some("Message text to queue on the target agent.".to_string()),
},
),
]);
ToolSpec::Function(ResponsesApiTool {
@@ -138,7 +143,7 @@ pub fn create_send_message_tool() -> ToolSpec {
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["target".to_string(), "items".to_string()]),
required: Some(vec!["target".to_string(), "message".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: Some(send_input_output_schema()),
@@ -155,7 +160,12 @@ pub fn create_assign_task_tool() -> ToolSpec {
),
},
),
("items".to_string(), create_collab_input_items_schema()),
(
"message".to_string(),
JsonSchema::String {
description: Some("Message text to send to the target agent.".to_string()),
},
),
(
"interrupt".to_string(),
JsonSchema::Boolean {
@@ -169,13 +179,13 @@ pub fn create_assign_task_tool() -> ToolSpec {
ToolSpec::Function(ResponsesApiTool {
name: "assign_task".to_string(),
description: "Add a message to an existing agent and trigger a turn in the target. Use interrupt=true to redirect work immediately. In MultiAgentV2, this tool currently supports text content only."
description: "Add a message to an existing non-root agent and trigger a turn in the target. Use interrupt=true to redirect work immediately. In MultiAgentV2, this tool currently supports text content only."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["target".to_string(), "items".to_string()]),
required: Some(vec!["target".to_string(), "message".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: Some(send_input_output_schema()),
@@ -221,7 +231,7 @@ pub fn create_wait_agent_tool_v1(options: WaitAgentTimeoutOptions) -> ToolSpec {
pub fn create_wait_agent_tool_v2(options: WaitAgentTimeoutOptions) -> ToolSpec {
ToolSpec::Function(ResponsesApiTool {
name: "wait_agent".to_string(),
description: "Wait for agents to reach a final status. Returns a brief wait summary instead of the agent's final content. Returns a timeout summary when no agent reaches a final status before the deadline."
description: "Wait for a mailbox update from any live agent, including queued messages and final-status notifications. Returns a brief wait summary instead of agent content, or a timeout summary if no mailbox update arrives before the deadline."
.to_string(),
strict: false,
defer_loading: None,
@@ -308,7 +318,7 @@ fn agent_status_output_schema() -> Value {
"oneOf": [
{
"type": "string",
"enum": ["pending_init", "running", "shutdown", "not_found"]
"enum": ["pending_init", "running", "interrupted", "shutdown", "not_found"]
},
{
"type": "object",
@@ -585,7 +595,12 @@ fn spawn_agent_common_properties_v1(agent_type_description: &str) -> BTreeMap<St
fn spawn_agent_common_properties_v2(agent_type_description: &str) -> BTreeMap<String, JsonSchema> {
BTreeMap::from([
("items".to_string(), create_collab_input_items_schema()),
(
"message".to_string(),
JsonSchema::String {
description: Some("Initial plain-text task for the new agent.".to_string()),
},
),
(
"agent_type".to_string(),
JsonSchema::String {

View File

@@ -56,9 +56,9 @@ fn spawn_agent_tool_v2_requires_task_name_and_lists_visible_models() {
assert!(description.contains("visible display (`visible-model`)"));
assert!(!description.contains("hidden display (`hidden-model`)"));
assert!(properties.contains_key("task_name"));
assert!(properties.contains_key("items"));
assert!(properties.contains_key("message"));
assert!(properties.contains_key("fork_turns"));
assert!(!properties.contains_key("message"));
assert!(!properties.contains_key("items"));
assert!(!properties.contains_key("fork_context"));
assert_eq!(
properties.get("agent_type"),
@@ -68,7 +68,7 @@ fn spawn_agent_tool_v2_requires_task_name_and_lists_visible_models() {
);
assert_eq!(
required,
Some(vec!["task_name".to_string(), "items".to_string()])
Some(vec!["task_name".to_string(), "message".to_string()])
);
assert_eq!(
output_schema.expect("spawn_agent output schema")["required"],
@@ -95,7 +95,7 @@ fn spawn_agent_tool_v1_keeps_legacy_fork_context_field() {
}
#[test]
fn send_message_tool_requires_items_and_uses_submission_output() {
fn send_message_tool_requires_message_and_uses_submission_output() {
let ToolSpec::Function(ResponsesApiTool {
parameters,
output_schema,
@@ -113,12 +113,12 @@ fn send_message_tool_requires_items_and_uses_submission_output() {
panic!("send_message should use object params");
};
assert!(properties.contains_key("target"));
assert!(properties.contains_key("items"));
assert!(properties.contains_key("message"));
assert!(!properties.contains_key("interrupt"));
assert!(!properties.contains_key("message"));
assert!(!properties.contains_key("items"));
assert_eq!(
required,
Some(vec!["target".to_string(), "items".to_string()])
Some(vec!["target".to_string(), "message".to_string()])
);
assert_eq!(
output_schema.expect("send_message output schema")["required"],
@@ -126,6 +126,38 @@ fn send_message_tool_requires_items_and_uses_submission_output() {
);
}
#[test]
fn assign_task_tool_requires_message_and_uses_submission_output() {
let ToolSpec::Function(ResponsesApiTool {
parameters,
output_schema,
..
}) = create_assign_task_tool()
else {
panic!("assign_task should be a function tool");
};
let JsonSchema::Object {
properties,
required,
..
} = parameters
else {
panic!("assign_task should use object params");
};
assert!(properties.contains_key("target"));
assert!(properties.contains_key("message"));
assert!(properties.contains_key("interrupt"));
assert!(!properties.contains_key("items"));
assert_eq!(
required,
Some(vec!["target".to_string(), "message".to_string()])
);
assert_eq!(
output_schema.expect("assign_task output schema")["required"],
json!(["submission_id"])
);
}
#[test]
fn wait_agent_tool_v2_uses_timeout_only_summary_output() {
let ToolSpec::Function(ResponsesApiTool {
@@ -176,3 +208,23 @@ fn list_agents_tool_includes_path_prefix_and_agent_fields() {
json!(["agent_name", "agent_status", "last_task_message"])
);
}
#[test]
fn list_agents_tool_status_schema_includes_interrupted() {
let ToolSpec::Function(ResponsesApiTool { output_schema, .. }) = create_list_agents_tool()
else {
panic!("list_agents should be a function tool");
};
assert_eq!(
output_schema.expect("list_agents output schema")["properties"]["agents"]["items"]["properties"]
["agent_status"]["allOf"][0]["oneOf"][0]["enum"],
json!([
"pending_init",
"running",
"interrupted",
"shutdown",
"not_found"
])
);
}