Files
codex/codex-rs/tools/src/tool_output.rs
T
jif 7b3eb8e4bc skills: expose remote skill resource tools (#27388)
## 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.
2026-06-11 12:38:04 +02:00

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
}