diff --git a/README.md b/README.md index c0365ec..3c49ce1 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,9 @@ inference, tool execution, and HTTP transport must be provided externally. DeepSeek V4.1 preprocessing with OpenCV. - **Tool definitions:** Function tools; the Responses API also supports tool namespaces and the `apply_patch` custom tool. +- **Codex agent input:** Responses `agent_message` items with plaintext `input_text` + content are preserved as user messages with sender and recipient attribution. + Encrypted, mixed, empty, or non-text agent content is rejected during conversion. ## Not yet supported diff --git a/README.zh.md b/README.zh.md index 2533c3a..b52a2cf 100644 --- a/README.zh.md +++ b/README.zh.md @@ -19,6 +19,7 @@ deepseek-recipe 包含一套 Rust 库以及对应的 Python bindings:支持将 - **输出解析:** 思考、工具调用、JSON object 输出和 stop sequence。 - **图片:** 支持以 base64 或外部 URL 传入。提供基于 OpenCV 的 DeepSeek V4.1 图片预处理。 - **工具定义:** 支持函数工具;Responses API 还支持工具命名空间和 `apply_patch` 自定义工具。 +- **Codex Agent 输入:** Responses 中包含明文 `input_text` 的 `agent_message` 会转换为注明发送者和接收者的用户消息。加密、混合、空白或非文本的 Agent 内容在转换时被拒绝。 ## 尚未支持 diff --git a/deepseek-recipe-python/tests/test_agent_messages.py b/deepseek-recipe-python/tests/test_agent_messages.py new file mode 100644 index 0000000..0974063 --- /dev/null +++ b/deepseek-recipe-python/tests/test_agent_messages.py @@ -0,0 +1,64 @@ +"""Codex agent input must reach the rendered prompt through the shared converter.""" + +from pathlib import Path + +import pytest + +from deepseek_recipe import ( + ConversionError, + ConversionOptions, + DeepseekV41Encoding, + ResponsesRequest, + Tokenizer, + UserMessage, +) + + +def agent_message(content): + return { + "type": "agent_message", + "author": "/root/worker", + "recipient": "/root", + "content": content, + } + + +def test_agent_message_reaches_prompt_and_token_ids(): + request = { + "model": "test-model", + "input": [agent_message([ + {"type": "input_text", "text": "Message Type: FINAL_ANSWER\nPayload:"}, + {"type": "input_text", "text": "CHILD_RESULT_92714: 世界 🦀"}, + ])], + } + converted = ResponsesRequest(request).convert(ConversionOptions()) + (message,) = converted.conversation.messages + assert isinstance(message, UserMessage) + assert "from /root/worker to /root" in message.content + assert "FINAL_ANSWER\nPayload:\nCHILD_RESULT_92714: 世界 🦀" in message.content + + tokenizer_path = Path(__file__).resolve().parents[2] / "static/tokenizers/v41/tokenizer.json" + tokenizer = Tokenizer.from_file(str(tokenizer_path)) + encoding = DeepseekV41Encoding().with_tokenizer(tokenizer) + prompt = encoding.render_conversation(converted.conversation).prompt + token_ids = encoding.encode(converted.conversation) + assert "CHILD_RESULT_92714: 世界 🦀" in prompt + assert token_ids + assert token_ids == tokenizer.encode(prompt) + + +@pytest.mark.parametrize("content", [ + [], + [{"type": "input_text", "text": " "}], + [{"type": "encrypted_content", "encrypted_content": "secret-ciphertext"}], + [ + {"type": "input_text", "text": "VISIBLE_PREFIX"}, + {"type": "encrypted_content", "encrypted_content": "secret-ciphertext"}, + ], +]) +def test_unreadable_agent_input_is_rejected(content): + request = ResponsesRequest({"model": "test-model", "input": [agent_message(content)]}) + with pytest.raises(ConversionError) as error: + request.convert(ConversionOptions()) + assert "secret-ciphertext" not in str(error.value) + assert "VISIBLE_PREFIX" not in str(error.value) diff --git a/deepseek-recipe/src/protocol/openai/responses/request/convert.rs b/deepseek-recipe/src/protocol/openai/responses/request/convert.rs index d05786f..f85fbce 100644 --- a/deepseek-recipe/src/protocol/openai/responses/request/convert.rs +++ b/deepseek-recipe/src/protocol/openai/responses/request/convert.rs @@ -20,11 +20,11 @@ use crate::request::{ use crate::stream::state_machine::{ParsingOptions, ReasoningStage}; use super::schema::{ - ResponsesContentBlock, ResponsesInput, ResponsesInputImage, ResponsesInputItem, - ResponsesInputMessage, ResponsesMessageContent, ResponsesMessageRole, ResponsesReasoningEffort, - ResponsesReasoningItem, ResponsesRequest, ResponsesTextConfig, ResponsesTextFormat, - ResponsesTool, ResponsesToolCallOutputContent, ResponsesToolCallOutputItem, - ResponsesTypedInputItem, + ResponsesAgentMessage, ResponsesContentBlock, ResponsesInput, ResponsesInputImage, + ResponsesInputItem, ResponsesInputMessage, ResponsesMessageContent, ResponsesMessageRole, + ResponsesReasoningEffort, ResponsesReasoningItem, ResponsesRequest, ResponsesTextConfig, + ResponsesTextFormat, ResponsesTool, ResponsesToolCallOutputContent, + ResponsesToolCallOutputItem, ResponsesTypedInputItem, }; use super::tools::ResponsesToolSet; @@ -125,6 +125,14 @@ fn item_contains_image_special_token(item: &ResponsesInputItem) -> bool { ResponsesTypedInputItem::Message(message) => { message_content_contains_image_special_token(&message.content) } + ResponsesTypedInputItem::AgentMessage(message) => { + message.author.contains(IMAGE_SPECIAL_TOKEN) + || message.recipient.contains(IMAGE_SPECIAL_TOKEN) + || message.content.iter().any(|block| { + matches!(block, ResponsesContentBlock::InputText { text } + if text.contains(IMAGE_SPECIAL_TOKEN)) + }) + } ResponsesTypedInputItem::FunctionCallOutput(output) => { output_content_contains_image_special_token(&output.output) } @@ -373,6 +381,42 @@ fn push_message( Ok(()) } +impl ResponsesAgentMessage { + /// Preserve incoming agent content as user input with explicit attribution. + fn into_user_message(self, item_idx: usize) -> Result { + let path = format!("input[{item_idx}]"); + if self.author.trim().is_empty() || self.recipient.trim().is_empty() { + return Err(ConversionError::bad_request(format!( + "{path}: agent_message requires a non-empty author and recipient", + ))); + } + let mut texts = Vec::with_capacity(self.content.len()); + for (block_idx, block) in self.content.into_iter().enumerate() { + match block { + ResponsesContentBlock::InputText { text } => texts.push(text), + _ => { + return Err(ConversionError::bad_request(format!( + "{path}.content[{block_idx}]: agent_message supports only plaintext input_text content", + ))); + } + } + } + let text = texts.join("\n"); + if text.trim().is_empty() { + return Err(ConversionError::bad_request(format!( + "{path}: agent_message requires non-empty plaintext content", + ))); + } + Ok(ResponsesInputMessage { + role: ResponsesMessageRole::User, + content: ResponsesMessageContent::String(format!( + "Agent message from {} to {}:\n{text}", + self.author, self.recipient, + )), + }) + } +} + fn push_reasoning(reasoning: ResponsesReasoningItem, messages: &mut Vec) { let text = reasoning .content @@ -433,6 +477,11 @@ fn transform_input_items( append_tool_call_group(&mut pending_group, messages); push_message(msg, item_idx, messages)?; } + ResponsesTypedInputItem::AgentMessage(msg) => { + check_pending_calls(&pending_call_ids)?; + append_tool_call_group(&mut pending_group, messages); + push_message(msg.into_user_message(item_idx)?, item_idx, messages)?; + } ResponsesTypedInputItem::Reasoning(reasoning) => { check_pending_calls(&pending_call_ids)?; append_tool_call_group(&mut pending_group, messages); diff --git a/deepseek-recipe/src/protocol/openai/responses/request/mod.rs b/deepseek-recipe/src/protocol/openai/responses/request/mod.rs index 0a6acb1..ef885ce 100644 --- a/deepseek-recipe/src/protocol/openai/responses/request/mod.rs +++ b/deepseek-recipe/src/protocol/openai/responses/request/mod.rs @@ -3,6 +3,9 @@ //! [`ResponsesRequest`] supports text, images, plaintext reasoning history, //! instructions, sampling parameters, reasoning effort, and JSON object output. //! Client tools include functions, namespaces, and the `apply_patch` custom tool. +//! Codex `agent_message` input items become attributed user messages, preserving +//! the sender, recipient, and plaintext content in history order. Encrypted, +//! mixed, empty, or non-text agent content is rejected rather than discarded. //! //! Document content, encrypted reasoning, conversation storage, and hosted tool //! execution are outside the supported scope. Web-search declarations and @@ -12,8 +15,8 @@ //! Input text must not spell out the image placeholder. Every placeholder in a //! prompt corresponds to one image source, and the adapter inserts them for //! image blocks only, so text input, instructions, message text, tool output -//! text, reasoning text, tool definitions, and historical tool calls that carry -//! the placeholder spelling are rejected. +//! text, reasoning text, agent text and attribution, tool definitions, and +//! historical tool calls that carry the placeholder spelling are rejected. //! //! JSON Schema output, `logprobs`, and `top_logprobs` are accepted and ignored. //! Function-tool `strict` settings are passed through for the caller to enforce. diff --git a/deepseek-recipe/src/protocol/openai/responses/request/schema.rs b/deepseek-recipe/src/protocol/openai/responses/request/schema.rs index b7a9048..3717d2e 100644 --- a/deepseek-recipe/src/protocol/openai/responses/request/schema.rs +++ b/deepseek-recipe/src/protocol/openai/responses/request/schema.rs @@ -58,6 +58,7 @@ impl<'de> Deserialize<'de> for ResponsesInputItem { #[serde(tag = "type", rename_all = "snake_case")] pub enum ResponsesTypedInputItem { Message(ResponsesInputMessage), + AgentMessage(ResponsesAgentMessage), FunctionCall(ResponsesFunctionCall), FunctionCallOutput(ResponsesFunctionCallOutput), Reasoning(ResponsesReasoningItem), @@ -68,6 +69,15 @@ pub enum ResponsesTypedInputItem { Unsupported, } +/// A Codex agent-to-agent input item. Only plaintext `input_text` blocks are +/// supported; encrypted or other content is rejected during conversion. +#[derive(Debug, Deserialize)] +pub struct ResponsesAgentMessage { + pub author: String, + pub recipient: String, + pub content: Vec, +} + #[derive(Debug, Deserialize)] pub struct ResponsesInputMessage { pub role: ResponsesMessageRole, diff --git a/deepseek-recipe/tests/responses_agent_message.rs b/deepseek-recipe/tests/responses_agent_message.rs new file mode 100644 index 0000000..3e71290 --- /dev/null +++ b/deepseek-recipe/tests/responses_agent_message.rs @@ -0,0 +1,214 @@ +use deepseek_recipe::openai::ResponsesRequest; +use deepseek_recipe::request::{ConversionError, ConversionOptions, ProtocolRequest}; +use deepseek_recipe_core::messages::InputMessage; +use deepseek_recipe_core::multimodal::IMAGE_SPECIAL_TOKEN; +use serde_json::{Value, json}; + +fn agent_message(author: &str, recipient: &str, content: Value) -> Value { + json!({ + "type": "agent_message", + "id": "amsg_test", + "author": author, + "recipient": recipient, + "content": content, + "internal_chat_message_metadata_passthrough": {"turn_id": "test-turn"}, + }) +} + +fn convert(input: Vec) -> Result, ConversionError> { + let request: ResponsesRequest = serde_json::from_value(json!({ + "model": "test-model", + "input": input, + })) + .unwrap(); + request + .convert(ConversionOptions::default()) + .map(|converted| converted.conversation.messages) +} + +#[test] +fn plaintext_agent_tasks_messages_and_results_remain_in_history_order() { + let mut input = vec![json!({"role": "user", "content": "Before agents"})]; + for (kind, author, recipient) in [ + ("NEW_TASK", "/root", "/root/worker"), + ("MESSAGE", "/root", "/root/worker"), + ("FINAL_ANSWER", "/root/worker", "/root"), + ] { + input.push(agent_message( + author, + recipient, + json!([ + {"type": "input_text", "text": format!("Message Type: {kind}\nPayload:")}, + {"type": "input_text", "text": format!("{kind} marker: 世界 🦀")}, + ]), + )); + } + input.push(json!({"role": "user", "content": "After agents"})); + let messages = convert(input).unwrap(); + assert_eq!(messages.len(), 5); + for (index, (kind, author, recipient)) in [ + ("NEW_TASK", "/root", "/root/worker"), + ("MESSAGE", "/root", "/root/worker"), + ("FINAL_ANSWER", "/root/worker", "/root"), + ] + .into_iter() + .enumerate() + { + let InputMessage::User { + content, + image_sources, + } = &messages[index + 1] + else { + panic!("An incoming agent message must be user input"); + }; + assert!(content.contains(author)); + assert!(content.contains(recipient)); + assert!(content.contains(&format!( + "Message Type: {kind}\nPayload:\n{kind} marker: 世界 🦀" + ))); + assert!(image_sources.is_empty()); + } + assert!( + matches!(&messages[0], InputMessage::User { content, .. } if content == "Before agents") + ); + assert!( + matches!(&messages[4], InputMessage::User { content, .. } if content == "After agents") + ); +} + +#[test] +fn agent_messages_preserve_completed_tool_groups() { + let messages = convert(vec![ + json!({"type": "function_call", "call_id": "check", "name": "check", "arguments": "{}"}), + json!({"type": "function_call_output", "call_id": "check", "output": "CHECK_COMPLETED"}), + agent_message("/root/worker", "/root", json!([{"type": "input_text", "text": "CHILD_RESULT"}])), + json!({"type": "custom_tool_call", "call_id": "patch", "name": "apply_patch", "input": "patch text"}), + json!({"type": "custom_tool_call_output", "call_id": "patch", "output": "PATCH_COMPLETED"}), + agent_message("/root", "/root/worker", json!([{"type": "input_text", "text": "FOLLOWUP_TASK"}])), + ]).unwrap(); + assert_eq!(messages.len(), 6); + assert!( + matches!(&messages[0], InputMessage::Assistant { tool_calls: Some(calls), .. } if calls[0].id == "check") + ); + assert!( + matches!(&messages[1], InputMessage::Tool { content, .. } if content == "CHECK_COMPLETED") + ); + assert!( + matches!(&messages[2], InputMessage::User { content, .. } if content.contains("CHILD_RESULT")) + ); + assert!( + matches!(&messages[3], InputMessage::Assistant { tool_calls: Some(calls), .. } if calls[0].id == "patch") + ); + assert!( + matches!(&messages[4], InputMessage::Tool { content, .. } if content == "PATCH_COMPLETED") + ); + assert!( + matches!(&messages[5], InputMessage::User { content, .. } if content.contains("FOLLOWUP_TASK")) + ); +} + +#[test] +fn agent_messages_do_not_bypass_unresolved_tool_calls() { + let error = convert(vec![ + json!({"type": "function_call", "call_id": "pending", "name": "check", "arguments": "{}"}), + agent_message( + "/root/worker", + "/root", + json!([{"type": "input_text", "text": "CHILD_RESULT"}]), + ), + json!({"type": "function_call_output", "call_id": "pending", "output": "CHECK_COMPLETED"}), + ]) + .unwrap_err(); + assert!(matches!(error, ConversionError::BadRequest { .. })); +} + +#[test] +fn encrypted_mixed_unknown_and_empty_agent_content_is_rejected() { + for content in [ + json!([]), + json!([{"type": "input_text", "text": " \n"}]), + json!([{"type": "encrypted_content", "encrypted_content": "secret-ciphertext"}]), + json!([ + {"type": "input_text", "text": "VISIBLE_PREFIX"}, + {"type": "encrypted_content", "encrypted_content": "secret-ciphertext"}, + ]), + json!([{"type": "unknown_content", "text": "OPAQUE"}]), + json!([{"type": "output_text", "text": "Not agent input text"}]), + json!([{"type": "input_image", "image_url": "https://example.com/image.png"}]), + json!([{"type": "input_file", "file_id": "file-test"}]), + ] { + let error = convert(vec![agent_message("/root", "/root/worker", content)]).unwrap_err(); + assert!(matches!(error, ConversionError::BadRequest { .. })); + assert!(!error.to_string().contains("secret-ciphertext")); + assert!(!error.to_string().contains("VISIBLE_PREFIX")); + } + for (author, recipient) in [("", "/root"), ("/root", " \n")] { + assert!( + convert(vec![agent_message( + author, + recipient, + json!([{"type": "input_text", "text": "TASK"}]) + )]) + .is_err() + ); + } +} + +#[test] +fn malformed_agent_items_fail_deserialization() { + for content in [ + Value::Null, + json!("TASK"), + json!([{"type": "input_text", "text": 42}]), + ] { + let request = json!({"model": "test-model", "input": [agent_message("/root", "/root/worker", content)]}); + assert!(serde_json::from_value::(request).is_err()); + } + for field in ["author", "recipient", "content"] { + let mut item = agent_message( + "/root", + "/root/worker", + json!([{"type": "input_text", "text": "TASK"}]), + ); + item.as_object_mut().unwrap().remove(field); + assert!( + serde_json::from_value::( + json!({"model": "test-model", "input": [item]}) + ) + .is_err() + ); + } +} + +#[test] +fn agent_text_and_attribution_follow_image_placeholder_validation() { + for (author, recipient, text) in [ + ("/root", "/root/worker", IMAGE_SPECIAL_TOKEN), + (IMAGE_SPECIAL_TOKEN, "/root/worker", "TASK"), + ("/root", IMAGE_SPECIAL_TOKEN, "TASK"), + ] { + let error = convert(vec![agent_message( + author, + recipient, + json!([{"type": "input_text", "text": text}]), + )]) + .unwrap_err(); + assert!(matches!(error, ConversionError::BadRequest { .. })); + } +} + +#[test] +fn unrelated_unsupported_items_keep_their_existing_behavior() { + let messages = convert(vec![ + json!({"type": "web_search_call"}), + json!({"role": "user", "content": "Continue"}), + ]) + .unwrap(); + assert_eq!( + messages, + vec![InputMessage::User { + content: "Continue".into(), + image_sources: vec![] + }] + ); +} diff --git a/server-py/tests/test_smoke.py b/server-py/tests/test_smoke.py index d89bf36..127da9a 100644 --- a/server-py/tests/test_smoke.py +++ b/server-py/tests/test_smoke.py @@ -83,6 +83,38 @@ def test_custom_inference(): assert response_text("/v1/responses", response.json()) == "Custom answer" +@pytest.mark.parametrize("stream", [False, True]) +def test_agent_input_reaches_inference_and_unreadable_content_does_not(stream): + prompts = [] + + async def infer(request): + prompts.append(request.prompt) + assert "from /root/worker to /root" in request.prompt + assert "CHILD_RESULT_92714" in request.prompt + yield InferenceChunk.text(THINKING_END_TOKEN + "Agent result received") + + item = { + "type": "agent_message", + "author": "/root/worker", + "recipient": "/root", + "content": [{"type": "input_text", "text": "CHILD_RESULT_92714"}], + } + with TestClient(SERVER.create_app(infer)) as client: + accepted = client.post("/v1/responses", json={ + "model": "test-model", "input": [item], "stream": stream, + }) + assert accepted.status_code == 200, accepted.text + assert "Agent result received" in accepted.text + encrypted = {"type": "encrypted_content", "encrypted_content": "secret-ciphertext"} + for content in [[encrypted], [*item["content"], encrypted]]: + rejected = client.post("/v1/responses", json={ + "model": "test-model", "input": [{**item, "content": content}], "stream": stream, + }) + assert rejected.status_code == 400, rejected.text + assert "secret-ciphertext" not in rejected.text + assert len(prompts) == 1 + + def test_invalid_request(): with TestClient(SERVER.app) as client: response = client.post("/v1/chat/completions", json={})