Compare commits

...

13 Commits

Author SHA1 Message Date
Michael Bolin
086ca7c728 bazel: lint rust_test targets in clippy workflow 2026-04-01 09:24:07 -07: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
22 changed files with 373 additions and 141 deletions

View File

@@ -5,15 +5,15 @@ The workflows in this directory are split so that pull requests get fast, review
## Pull Requests
- `bazel.yml` is the main pre-merge verification path for Rust code.
It runs Bazel `test` and Bazel `clippy` on the supported Bazel targets.
It runs Bazel `test` and Bazel `clippy` on the supported Bazel targets,
including the generated Rust test binaries needed to lint inline `#[cfg(test)]`
code.
- `rust-ci.yml` keeps the Cargo-native PR checks intentionally small:
- `cargo fmt --check`
- `cargo shear`
- `argument-comment-lint` on Linux, macOS, and Windows
- `tools/argument-comment-lint` package tests when the lint or its workflow wiring changes
The PR workflow still keeps the Linux lint lane on the default-targets-only invocation for now, but the released linter runs on Linux, macOS, and Windows before merge.
## Post-Merge On `main`
- `bazel.yml` also runs on pushes to `main`.

View File

@@ -126,13 +126,17 @@ jobs:
with:
target: ${{ matrix.target }}
- name: bazel build --config=clippy //codex-rs/...
- name: bazel build --config=clippy lint targets
env:
BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }}
shell: bash
run: |
# Keep the initial Bazel clippy scope on codex-rs and out of the
# V8 proof-of-concept target for now.
bazel_target_lines="$(./scripts/list-bazel-clippy-targets.sh)"
bazel_targets=()
while IFS= read -r target; do
bazel_targets+=("${target}")
done <<< "${bazel_target_lines}"
./.github/scripts/run-bazel-ci.sh \
-- \
build \
@@ -140,8 +144,7 @@ jobs:
--build_metadata=COMMIT_SHA=${GITHUB_SHA} \
--build_metadata=TAG_job=clippy \
-- \
//codex-rs/... \
-//codex-rs/v8-poc:all
"${bazel_targets[@]}"
# Save bazel repository cache explicitly; make non-fatal so cache uploading
# never fails the overall job. Only save when key wasn't hit.

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

@@ -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

@@ -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"
])
);
}

View File

@@ -69,8 +69,9 @@ bazel-lock-check:
bazel-test:
bazel test --test_tag_filters=-argument-comment-lint //... --keep_going
[no-cd]
bazel-clippy:
bazel build --config=clippy -- //codex-rs/... -//codex-rs/v8-poc:all
bazel_targets="$(./scripts/list-bazel-clippy-targets.sh)" && bazel build --config=clippy -- ${bazel_targets}
[no-cd]
bazel-argument-comment-lint:

View File

@@ -0,0 +1,20 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${repo_root}"
# Resolve the dynamic targets before printing anything so callers do not
# continue with a partial list if `bazel query` fails.
manual_rust_test_targets="$(bazel query 'kind("rust_test rule", attr(tags, "manual", //codex-rs/... except //codex-rs/v8-poc/...))')"
printf '%s\n' \
"//codex-rs/..." \
"-//codex-rs/v8-poc:all"
# `--config=clippy` on the `workspace_root_test` wrappers does not lint the
# underlying `rust_test` binaries. Add the internal manual `*-unit-tests-bin`
# targets explicitly so inline `#[cfg(test)]` code is linted like
# `cargo clippy --tests`.
printf '%s\n' "${manual_rust_test_targets}"