mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
7b3eb8e4bc
## Why PR #27387 makes backend plugin skills discoverable and invocable without an executor, but resources referenced by those skills still sit behind the generic MCP resource surface. The model needs a skills-owned API that preserves the provider authority and package boundary instead of treating remote resources like local files. This is stacked on #27387. ## What - Adds one `skills` namespace with bounded `list` and `read` tools for remote skill providers. - Revalidates `authority + package` against the live remote catalog on every read, then routes the opaque resource ID back through that provider. - Allows the backend provider to read canonical child `skill://` resources while rejecting cross-package, non-canonical, query, fragment, and traversal-shaped URIs. - Caps each serialized tool result at 8 KB. Lists are paginated; reads return an opaque continuation cursor. - Marks the JSON output as external context so memory generation can apply its normal suppression policy. - Deliberately does not add `skills.search`; that waits for a bounded plugin-service search contract. ## Tool contract Pseudo-Python matching the wire shape: ```python from typing import Literal, NotRequired, TypedDict class RemoteSkillAuthority(TypedDict): kind: Literal["remote"] id: str # e.g. "codex_apps" class RemoteSkill(TypedDict): authority: RemoteSkillAuthority package: str # opaque provider-owned package ID name: str description: str main_resource: str # opaque provider-owned SKILL.md ID class SkillsListParams(TypedDict): cursor: NotRequired[str] class SkillsListResult(TypedDict): skills: list[RemoteSkill] next_cursor: str | None warnings: list[str] truncated: bool class SkillsReadParams(TypedDict): authority: RemoteSkillAuthority # copied from skills.list package: str # copied from skills.list resource: str # provider-owned child resource ID cursor: NotRequired[str] # copy next_cursor to continue class SkillsReadResult(TypedDict): resource: str contents: str next_cursor: str | None truncated: bool class Skills: def list(self, params: SkillsListParams) -> SkillsListResult: ... def read(self, params: SkillsReadParams) -> SkillsReadResult: ... ``` There is one namespace for all remote skills, not one tool or MCP server per skill. No resource ID is converted into a filesystem path. ## Backend dependency `/ps/mcp` must support direct reads of child resources such as `skill://plugin_demo/deploy/references/deploy.md`. This PR implements and tests the Codex side of that contract; production child reads remain dependent on the corresponding plugin-service support. Search remains out of scope until that service exposes a bounded search/resource API. ## Validation - Added an app-server integration test covering `skills.list` followed by `skills.read` with no executor. - Ran `just fmt`. - Ran `just bazel-lock-update` and `just bazel-lock-check`. - Did not run Rust tests or Clippy locally, per request; CI will run them.
284 lines
9.2 KiB
Rust
284 lines
9.2 KiB
Rust
use codex_protocol::models::DEFAULT_IMAGE_DETAIL;
|
|
use codex_protocol::models::FunctionCallOutputBody;
|
|
use codex_protocol::models::FunctionCallOutputContentItem;
|
|
use codex_protocol::models::FunctionCallOutputPayload;
|
|
use codex_protocol::models::ResponseInputItem;
|
|
use codex_utils_string::take_bytes_at_char_boundary;
|
|
use serde_json::Value as JsonValue;
|
|
|
|
use crate::ToolPayload;
|
|
|
|
const TELEMETRY_PREVIEW_MAX_BYTES: usize = 2 * 1024;
|
|
const TELEMETRY_PREVIEW_MAX_LINES: usize = 64;
|
|
const TELEMETRY_PREVIEW_TRUNCATION_NOTICE: &str = "[... telemetry preview truncated ...]";
|
|
|
|
/// Model-facing output contract returned by executable tool runtimes.
|
|
pub trait ToolOutput: Send {
|
|
fn log_preview(&self) -> String;
|
|
|
|
fn success_for_logging(&self) -> bool;
|
|
|
|
/// Whether this output contains external context that should disable memory generation when
|
|
/// `memories.disable_on_external_context` is enabled.
|
|
fn contains_external_context(&self) -> bool {
|
|
false
|
|
}
|
|
|
|
fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem;
|
|
|
|
/// Returns the tool call id exposed to `PostToolUse` hooks for this output.
|
|
fn post_tool_use_id(&self, call_id: &str) -> String {
|
|
call_id.to_string()
|
|
}
|
|
|
|
/// Returns the tool input exposed to `PostToolUse` hooks for this output.
|
|
fn post_tool_use_input(&self, _payload: &ToolPayload) -> Option<JsonValue> {
|
|
None
|
|
}
|
|
|
|
/// Returns the stable value exposed to `PostToolUse` hooks for this tool output.
|
|
///
|
|
/// Tool handlers decide whether a tool participates in `PostToolUse`, but
|
|
/// this method lets the output type own any conversion from model-facing
|
|
/// response content to hook-facing data. Returning `None` means the output
|
|
/// should not produce a post-use hook payload, not merely that the tool had
|
|
/// empty output.
|
|
fn post_tool_use_response(&self, _call_id: &str, _payload: &ToolPayload) -> Option<JsonValue> {
|
|
None
|
|
}
|
|
|
|
fn code_mode_result(&self, payload: &ToolPayload) -> JsonValue {
|
|
response_input_to_code_mode_result(self.to_response_item("", payload))
|
|
}
|
|
}
|
|
|
|
impl<T> ToolOutput for Box<T>
|
|
where
|
|
T: ToolOutput + ?Sized,
|
|
{
|
|
fn log_preview(&self) -> String {
|
|
(**self).log_preview()
|
|
}
|
|
|
|
fn success_for_logging(&self) -> bool {
|
|
(**self).success_for_logging()
|
|
}
|
|
|
|
fn contains_external_context(&self) -> bool {
|
|
(**self).contains_external_context()
|
|
}
|
|
|
|
fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem {
|
|
(**self).to_response_item(call_id, payload)
|
|
}
|
|
|
|
fn post_tool_use_id(&self, call_id: &str) -> String {
|
|
(**self).post_tool_use_id(call_id)
|
|
}
|
|
|
|
fn post_tool_use_input(&self, payload: &ToolPayload) -> Option<JsonValue> {
|
|
(**self).post_tool_use_input(payload)
|
|
}
|
|
|
|
fn post_tool_use_response(&self, call_id: &str, payload: &ToolPayload) -> Option<JsonValue> {
|
|
(**self).post_tool_use_response(call_id, payload)
|
|
}
|
|
|
|
fn code_mode_result(&self, payload: &ToolPayload) -> JsonValue {
|
|
(**self).code_mode_result(payload)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct JsonToolOutput {
|
|
value: JsonValue,
|
|
success: Option<bool>,
|
|
contains_external_context: bool,
|
|
}
|
|
|
|
impl JsonToolOutput {
|
|
pub fn new(value: JsonValue) -> Self {
|
|
Self {
|
|
value,
|
|
success: Some(true),
|
|
contains_external_context: false,
|
|
}
|
|
}
|
|
|
|
pub fn with_success(value: JsonValue, success: Option<bool>) -> Self {
|
|
Self {
|
|
value,
|
|
success,
|
|
contains_external_context: false,
|
|
}
|
|
}
|
|
|
|
pub fn with_external_context(mut self) -> Self {
|
|
self.contains_external_context = true;
|
|
self
|
|
}
|
|
}
|
|
|
|
impl ToolOutput for JsonToolOutput {
|
|
fn log_preview(&self) -> String {
|
|
telemetry_preview(&self.value.to_string())
|
|
}
|
|
|
|
fn success_for_logging(&self) -> bool {
|
|
self.success.unwrap_or(true)
|
|
}
|
|
|
|
fn contains_external_context(&self) -> bool {
|
|
self.contains_external_context
|
|
}
|
|
|
|
fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem {
|
|
let output = FunctionCallOutputPayload {
|
|
body: FunctionCallOutputBody::Text(self.value.to_string()),
|
|
success: self.success,
|
|
};
|
|
|
|
if matches!(payload, ToolPayload::Custom { .. }) {
|
|
return ResponseInputItem::CustomToolCallOutput {
|
|
call_id: call_id.to_string(),
|
|
name: None,
|
|
output,
|
|
};
|
|
}
|
|
|
|
ResponseInputItem::FunctionCallOutput {
|
|
call_id: call_id.to_string(),
|
|
output,
|
|
}
|
|
}
|
|
|
|
fn post_tool_use_response(&self, _call_id: &str, _payload: &ToolPayload) -> Option<JsonValue> {
|
|
Some(self.value.clone())
|
|
}
|
|
|
|
fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue {
|
|
self.value.clone()
|
|
}
|
|
}
|
|
|
|
impl ToolOutput for codex_protocol::mcp::CallToolResult {
|
|
fn log_preview(&self) -> String {
|
|
let output = self.as_function_call_output_payload();
|
|
let preview = output.body.to_text().unwrap_or_else(|| output.to_string());
|
|
telemetry_preview(&preview)
|
|
}
|
|
|
|
fn success_for_logging(&self) -> bool {
|
|
self.success()
|
|
}
|
|
|
|
fn to_response_item(&self, call_id: &str, _payload: &ToolPayload) -> ResponseInputItem {
|
|
ResponseInputItem::McpToolCallOutput {
|
|
call_id: call_id.to_string(),
|
|
output: self.clone(),
|
|
}
|
|
}
|
|
|
|
fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue {
|
|
serde_json::to_value(self).unwrap_or_else(|err| {
|
|
JsonValue::String(format!("failed to serialize mcp result: {err}"))
|
|
})
|
|
}
|
|
}
|
|
|
|
fn response_input_to_code_mode_result(response: ResponseInputItem) -> JsonValue {
|
|
match response {
|
|
ResponseInputItem::Message { content, .. } => content_items_to_code_mode_result(
|
|
&content
|
|
.into_iter()
|
|
.map(|item| match item {
|
|
codex_protocol::models::ContentItem::InputText { text }
|
|
| codex_protocol::models::ContentItem::OutputText { text } => {
|
|
FunctionCallOutputContentItem::InputText { text }
|
|
}
|
|
codex_protocol::models::ContentItem::InputImage { image_url, detail } => {
|
|
FunctionCallOutputContentItem::InputImage {
|
|
image_url,
|
|
detail: detail.or(Some(DEFAULT_IMAGE_DETAIL)),
|
|
}
|
|
}
|
|
})
|
|
.collect::<Vec<_>>(),
|
|
),
|
|
ResponseInputItem::FunctionCallOutput { output, .. }
|
|
| ResponseInputItem::CustomToolCallOutput { output, .. } => match output.body {
|
|
FunctionCallOutputBody::Text(text) => JsonValue::String(text),
|
|
FunctionCallOutputBody::ContentItems(items) => {
|
|
content_items_to_code_mode_result(&items)
|
|
}
|
|
},
|
|
ResponseInputItem::ToolSearchOutput { tools, .. } => JsonValue::Array(tools),
|
|
ResponseInputItem::McpToolCallOutput { output, .. } => serde_json::to_value(output)
|
|
.unwrap_or_else(|err| {
|
|
JsonValue::String(format!("failed to serialize mcp result: {err}"))
|
|
}),
|
|
}
|
|
}
|
|
|
|
fn content_items_to_code_mode_result(items: &[FunctionCallOutputContentItem]) -> JsonValue {
|
|
JsonValue::String(
|
|
items
|
|
.iter()
|
|
.filter_map(|item| match item {
|
|
FunctionCallOutputContentItem::InputText { text } if !text.trim().is_empty() => {
|
|
Some(text.clone())
|
|
}
|
|
FunctionCallOutputContentItem::InputImage { image_url, .. }
|
|
if !image_url.trim().is_empty() =>
|
|
{
|
|
Some(image_url.clone())
|
|
}
|
|
FunctionCallOutputContentItem::InputText { .. }
|
|
| FunctionCallOutputContentItem::InputImage { .. }
|
|
| FunctionCallOutputContentItem::EncryptedContent { .. } => None,
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n"),
|
|
)
|
|
}
|
|
|
|
fn telemetry_preview(content: &str) -> String {
|
|
let truncated_slice = take_bytes_at_char_boundary(content, TELEMETRY_PREVIEW_MAX_BYTES);
|
|
let truncated_by_bytes = truncated_slice.len() < content.len();
|
|
|
|
let mut preview = String::new();
|
|
let mut lines_iter = truncated_slice.lines();
|
|
for idx in 0..TELEMETRY_PREVIEW_MAX_LINES {
|
|
match lines_iter.next() {
|
|
Some(line) => {
|
|
if idx > 0 {
|
|
preview.push('\n');
|
|
}
|
|
preview.push_str(line);
|
|
}
|
|
None => break,
|
|
}
|
|
}
|
|
let truncated_by_lines = lines_iter.next().is_some();
|
|
|
|
if !truncated_by_bytes && !truncated_by_lines {
|
|
return content.to_string();
|
|
}
|
|
|
|
if preview.len() < truncated_slice.len()
|
|
&& truncated_slice
|
|
.as_bytes()
|
|
.get(preview.len())
|
|
.is_some_and(|byte| *byte == b'\n')
|
|
{
|
|
preview.push('\n');
|
|
}
|
|
|
|
if !preview.is_empty() && !preview.ends_with('\n') {
|
|
preview.push('\n');
|
|
}
|
|
preview.push_str(TELEMETRY_PREVIEW_TRUNCATION_NOTICE);
|
|
|
|
preview
|
|
}
|