mirror of
https://github.com/openai/codex.git
synced 2026-09-06 15:29:32 +00:00
## Summary
Adds experimental `additionalContext` support to `turn/start` and
`turn/steer` so clients can provide ephemeral external context, such as
browser or automation state, without turning that plumbing into a
visible user prompt or triggering user-prompt lifecycle behavior.
## API Shape
The parameter shape is:
```ts
additionalContext?: Record<string, {
value: string
kind: "untrusted" | "application"
}> | null
```
Example:
```json
{
"additionalContext": {
"browser_info": {
"value": "Active tab is CI failures.",
"kind": "untrusted"
},
"automation_info": {
"value": "CI rerun is in progress.",
"kind": "application"
}
}
}
```
The keys are opaque and caller-defined.
## Context Injection
When provided, accepted entries are inserted into model context as
hidden contextual message items, not as visible thread user-message
items.
`kind: "untrusted"` entries are inserted with role `user`:
```text
<external_${key}>${value}</external_${key}>
```
`kind: "application"` entries are inserted with role `developer`:
```text
<${key}>${value}</${key}>
```
Values are not escaped. Each value is truncated to 1k approximate tokens
before wrapping.
For `turn/start`, accepted additional context is inserted before normal
user input. For `turn/steer`, additional context is merged only when the
steer includes non-empty user input; context-only steers still reject as
empty input.
## Dedupe Strategy
`AdditionalContextStore` lives on session state and stores the latest
complete additional-context map.
Each `turn/start` or non-empty `turn/steer` treats its
`additionalContext` as the current complete set of values. Entries are
injected only when the key is new or the exact entry for that key
changed, including `value` or `kind`. After merging, the store is
replaced with the provided map, so omitted keys are removed from the
retained set and can be injected again later if reintroduced.
Omitting `additionalContext`, passing `null`, or passing an empty object
resets the store to empty and injects nothing.
## What Changed
- Threads experimental v2 `additionalContext` through app-server into
core turn start and steer handling.
- Adds separate contextual fragment types for untrusted user-role
context and application developer-role context.
- Uses pending response input items so additional context can be
combined with normal user input without treating it as prompt text.
- Adds integration coverage for start/steer flow, role routing,
dedupe/reset behavior, deletion/re-add behavior, hook-blocked input
behavior, empty context-only steer rejection, external-fragment marker
matching, and truncation.
115 lines
3.4 KiB
Rust
115 lines
3.4 KiB
Rust
use codex_protocol::models::ContentItem;
|
|
use codex_protocol::models::ResponseInputItem;
|
|
use codex_protocol::models::ResponseItem;
|
|
use std::marker::PhantomData;
|
|
|
|
/// Type-erased registration for a contextual user fragment.
|
|
///
|
|
/// Implementations are used by context filtering code to recognize injected
|
|
/// fragments without constructing the concrete context payload.
|
|
pub(crate) trait FragmentRegistration: Sync {
|
|
fn matches_text(&self, text: &str) -> bool;
|
|
}
|
|
|
|
pub(crate) struct FragmentRegistrationProxy<T> {
|
|
_marker: PhantomData<fn() -> T>,
|
|
}
|
|
|
|
impl<T> FragmentRegistrationProxy<T> {
|
|
pub(crate) const fn new() -> Self {
|
|
Self {
|
|
_marker: PhantomData,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<T: ContextualUserFragment> FragmentRegistration for FragmentRegistrationProxy<T> {
|
|
fn matches_text(&self, text: &str) -> bool {
|
|
T::matches_text(text)
|
|
}
|
|
}
|
|
|
|
/// Context payload that is injected as a message fragment.
|
|
///
|
|
/// Implementations own the response role and provide the exact fragment body.
|
|
/// Marked fragments also provide start/end markers used to recognize injected
|
|
/// context later. `render()` concatenates markers and body without adding
|
|
/// separators, so implementations should include any whitespace they need
|
|
/// between tags in `body()`. Unmarked fragments should leave both markers empty,
|
|
/// in which case the default helpers render only the body and never match
|
|
/// arbitrary text.
|
|
pub trait ContextualUserFragment {
|
|
fn role() -> &'static str
|
|
where
|
|
Self: Sized;
|
|
|
|
fn markers(&self) -> (&'static str, &'static str);
|
|
|
|
fn body(&self) -> String;
|
|
|
|
fn type_markers() -> (&'static str, &'static str)
|
|
where
|
|
Self: Sized;
|
|
|
|
fn matches_text(text: &str) -> bool
|
|
where
|
|
Self: Sized,
|
|
{
|
|
let (start_marker, end_marker) = Self::type_markers();
|
|
matches_marked_text(start_marker, end_marker, text)
|
|
}
|
|
|
|
fn render(&self) -> String {
|
|
let (start_marker, end_marker) = self.markers();
|
|
let body = self.body();
|
|
if start_marker.is_empty() && end_marker.is_empty() {
|
|
return body;
|
|
}
|
|
|
|
format!("{start_marker}{body}{end_marker}")
|
|
}
|
|
|
|
fn into(self) -> ResponseItem
|
|
where
|
|
Self: Sized,
|
|
{
|
|
ResponseItem::Message {
|
|
id: None,
|
|
role: Self::role().to_string(),
|
|
content: vec![ContentItem::InputText {
|
|
text: self.render(),
|
|
}],
|
|
phase: None,
|
|
}
|
|
}
|
|
|
|
fn into_response_input_item(self) -> ResponseInputItem
|
|
where
|
|
Self: Sized,
|
|
{
|
|
ResponseInputItem::Message {
|
|
role: Self::role().to_string(),
|
|
content: vec![ContentItem::InputText {
|
|
text: self.render(),
|
|
}],
|
|
phase: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn matches_marked_text(start_marker: &str, end_marker: &str, text: &str) -> bool {
|
|
if start_marker.is_empty() || end_marker.is_empty() {
|
|
return false;
|
|
}
|
|
|
|
let trimmed = text.trim_start();
|
|
let starts_with_marker = trimmed
|
|
.get(..start_marker.len())
|
|
.is_some_and(|candidate| candidate.eq_ignore_ascii_case(start_marker));
|
|
let trimmed = trimmed.trim_end();
|
|
let ends_with_marker = trimmed
|
|
.get(trimmed.len().saturating_sub(end_marker.len())..)
|
|
.is_some_and(|candidate| candidate.eq_ignore_ascii_case(end_marker));
|
|
starts_with_marker && ends_with_marker
|
|
}
|