Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
655f4c2
refactor: share provider extraction strategies
yczhang-nv Jun 26, 2026
a82bdff
docs: clarify response extraction contract
yczhang-nv Jun 26, 2026
727eafc
docs: note extraction shim migration
yczhang-nv Jun 26, 2026
d25aaae
test: assert adapter fallback boundary
yczhang-nv Jun 26, 2026
0bfc124
test: cover agent payload extraction
yczhang-nv Jun 26, 2026
d879ba6
fix: avoid promoting user email metadata
yczhang-nv Jun 26, 2026
ad1fbb8
test: cover anthropic hinted request decode
yczhang-nv Jun 26, 2026
fb9258f
fix: avoid path metadata promotion
yczhang-nv Jun 26, 2026
20816ae
fix: normalize requests with provider hints
yczhang-nv Jun 26, 2026
720e19e
refactor: clarify extraction naming
yczhang-nv Jun 26, 2026
2e3cb6f
fix: restrict anthropic provider hints
yczhang-nv Jun 26, 2026
359ff7e
test: assert cwd payload preservation
yczhang-nv Jun 26, 2026
afada10
refactor: share CLI JSON extraction primitives
yczhang-nv Jun 30, 2026
72d90ad
refactor: split CLI extraction strategies
yczhang-nv Jun 30, 2026
aab6b72
Merge remote-tracking branch 'upstream/main' into refactor/shared-ext…
yczhang-nv Jun 30, 2026
9a8770b
docs: document CLI extraction contracts
yczhang-nv Jun 30, 2026
421423c
fix: stabilize extraction route fallbacks
yczhang-nv Jun 30, 2026
e53b663
Merge remote-tracking branch 'upstream/main' into refactor/shared-ext…
yczhang-nv Jun 30, 2026
d18d586
docs: clarify provider request extraction
yczhang-nv Jun 30, 2026
85822a9
refactor: model agent extractor deviations via trait defaults
yczhang-nv Jun 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
300 changes: 202 additions & 98 deletions crates/cli/src/adapters/mod.rs

Large diffs are not rendered by default.

55 changes: 47 additions & 8 deletions crates/cli/src/alignment/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,47 @@ impl GatewayManagementPolicy {
}
}

/// Strategy for extracting provider-request facts used by gateway alignment.
///
/// This stays separate from [`SessionAlignmentState`] because extraction is a
/// stateless read of request JSON, while ownership resolution is stateful and
/// depends on active scopes, hints, aliases, and recent tool activity.
pub(crate) trait ProviderRequestExtractor {
fn request_affinity_key(&self, request: &LlmRequest) -> Option<String>;
fn gateway_turn_input(
&self,
agent_kind: AgentKind,
provider: &str,
request: &LlmRequest,
) -> Option<Value>;
}

struct CommonProviderRequestExtractor;

static COMMON_PROVIDER_REQUEST_EXTRACTOR: CommonProviderRequestExtractor =
CommonProviderRequestExtractor;

impl ProviderRequestExtractor for CommonProviderRequestExtractor {
fn request_affinity_key(&self, request: &LlmRequest) -> Option<String> {
let task_text = request_user_task_text(&request.content)?;
let normalized = normalize_affinity_text(&task_text);
(normalized.chars().count() >= REQUEST_AFFINITY_KEY_MIN_CHARS)
.then(|| truncate_affinity_text(&normalized, REQUEST_AFFINITY_KEY_MAX_CHARS))
}

fn gateway_turn_input(
&self,
agent_kind: AgentKind,
provider: &str,
request: &LlmRequest,
) -> Option<Value> {
if agent_kind != AgentKind::ClaudeCode || provider != "anthropic.messages" {
return None;
}
request_user_task_text(&request.content).map(|prompt| json!({ "prompt": prompt }))
}
}

// Records that a provider-created child session is really a subagent under another session. The
// session manager stores this until the child emits its terminal AgentEnded event, then removes the
// alias so future unrelated events cannot be reparented through stale state.
Expand Down Expand Up @@ -562,11 +603,12 @@ pub(crate) fn llm_owner_metadata(scope_metadata: Option<&Value>) -> Value {
// worker id; this key lets session correlation pair those calls with the subagent that first owned
// the task. The extractor understands Anthropic Messages, OpenAI Chat Completions, and OpenAI
// Responses shapes, and deliberately ignores raw count-token/file payloads.
// TODO(extraction): These free-function shims preserve existing alignment call
// sites while host-specific extractors land. Move callers onto
// `ProviderRequestExtractor` implementations and remove the shims after that
// migration completes.
pub(crate) fn request_affinity_key(request: &LlmRequest) -> Option<String> {
let task_text = request_user_task_text(&request.content)?;
let normalized = normalize_affinity_text(&task_text);
(normalized.chars().count() >= REQUEST_AFFINITY_KEY_MIN_CHARS)
.then(|| truncate_affinity_text(&normalized, REQUEST_AFFINITY_KEY_MAX_CHARS))
COMMON_PROVIDER_REQUEST_EXTRACTOR.request_affinity_key(request)
}

// Builds a non-null turn input when a direct gateway request arrives before the prompt hook. This
Expand All @@ -577,10 +619,7 @@ pub(crate) fn gateway_turn_input(
provider: &str,
request: &LlmRequest,
) -> Option<Value> {
if agent_kind != AgentKind::ClaudeCode || provider != "anthropic.messages" {
return None;
}
request_user_task_text(&request.content).map(|prompt| json!({ "prompt": prompt }))
COMMON_PROVIDER_REQUEST_EXTRACTOR.gateway_turn_input(agent_kind, provider, request)
}

// Detects tool results that imply a subagent completed. Claude Code reports this through the
Expand Down
88 changes: 87 additions & 1 deletion crates/cli/tests/coverage/adapters_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,91 @@ fn adapter_string_lookup_accepts_scalar_values_only() {
assert_eq!(string_at(&payload, &["object"]), None);
}

#[test]
fn common_extractor_keeps_fallbacks_at_adapter_boundary() {
let headers = HeaderMap::new();
let payload = json!({});

assert_eq!(
COMMON_AGENT_PAYLOAD_EXTRACTOR.session_id(&payload, &headers),
None
);
assert_eq!(COMMON_AGENT_PAYLOAD_EXTRACTOR.event_name(&payload), None);
assert_eq!(
COMMON_AGENT_PAYLOAD_EXTRACTOR.subagent_id(&payload, &headers),
None
);
assert_eq!(
COMMON_AGENT_PAYLOAD_EXTRACTOR.llm_hint(&payload, &headers),
ExtractedLlmHint::default()
);
assert_eq!(
COMMON_AGENT_PAYLOAD_EXTRACTOR.tool_call(&payload, &headers, "PreToolUse"),
ExtractedToolCall {
tool_call_id: None,
tool_name: None,
subagent_id: None,
arguments: None,
result: None,
status: None,
}
);

assert!(session_id(&payload, &headers).starts_with("hook-"));
assert_eq!(event_name(&payload), "unknown");

let event = common_tool_event(&payload, &headers, AgentKind::ClaudeCode);
assert!(event.tool_call_id.starts_with("tool-"));
assert_eq!(event.tool_name, "unknown_tool");
assert_eq!(event.arguments, json!(null));
assert_eq!(event.result, json!(null));
}

#[test]
fn common_extractor_reads_agent_hint_and_tool_call_fields() {
let headers = HeaderMap::new();
let payload = json!({
"subagent_id": "worker-1",
"agent": {
"id": "agent-1",
"type": "reviewer"
},
"conversationId": "conversation-1",
"generation": { "id": "generation-1" },
"request": { "id": "request-1" },
"modelName": "gpt-test",
"tool_call_id": "tool-call-1",
"tool": { "name": "search" },
"arguments": { "query": "needle" },
"result": { "matches": 2 },
"status": "success"
});

assert_eq!(
COMMON_AGENT_PAYLOAD_EXTRACTOR.llm_hint(&payload, &headers),
ExtractedLlmHint {
subagent_id: Some("worker-1".into()),
agent_id: Some("agent-1".into()),
agent_type: Some("reviewer".into()),
conversation_id: Some("conversation-1".into()),
generation_id: Some("generation-1".into()),
request_id: Some("request-1".into()),
model: Some("gpt-test".into()),
}
);
assert_eq!(
COMMON_AGENT_PAYLOAD_EXTRACTOR.tool_call(&payload, &headers, "PostToolUse"),
ExtractedToolCall {
tool_call_id: Some("tool-call-1".into()),
tool_name: Some("search".into()),
subagent_id: Some("worker-1".into()),
arguments: Some(json!({ "query": "needle" })),
result: Some(json!({ "matches": 2 })),
status: Some("success".into()),
}
);
}

#[test]
fn maps_cursor_subagent_and_permission_response() {
let headers = HeaderMap::new();
Expand All @@ -252,7 +337,8 @@ fn maps_cursor_subagent_and_permission_response() {
assert_eq!(event.session_id, "cursor-session");
assert_eq!(event.subagent_id.as_deref(), Some("worker"));
assert_eq!(event.metadata["project_dir"], json!("/repo"));
assert_eq!(event.metadata["user_email"], json!("dev@example.com"));
assert!(event.metadata.get("user_email").is_none());
assert_eq!(event.payload["user_email"], json!("dev@example.com"));
}
event => panic!("unexpected event: {event:?}"),
}
Expand Down
8 changes: 2 additions & 6 deletions crates/core/src/codec/anthropic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ use super::request::{
AnnotatedLlmRequest, FunctionDefinition, GenerationParams, Message, MessageContent, ToolChoice,
ToolChoiceFunction, ToolChoiceFunctionName, ToolDefinition,
};
use super::resolve::{ProviderSurface, SurfaceDescriptor};
use super::resolve::{ProviderSurface, ProviderSurfaceDescriptor};
use super::response::{
AnnotatedLlmResponse, ApiSpecificResponse, FinishReason, RawUsageCost, ResponseToolCall, Usage,
estimate_cost_for_provider, infer_model_provider, provider_reported_cost,
Expand All @@ -40,11 +40,7 @@ use super::traits::{LlmCodec, LlmResponseCodec};
/// Built-in codec for the Anthropic Messages API.
pub struct AnthropicMessagesCodec;

// ---------------------------------------------------------------------------
// Built-in surface descriptor (codec-owned detection, registered in resolve)
// ---------------------------------------------------------------------------

pub(crate) const SURFACE_DESCRIPTOR: SurfaceDescriptor = SurfaceDescriptor {
pub(crate) const PROVIDER_SURFACE: ProviderSurfaceDescriptor = ProviderSurfaceDescriptor {
surface: ProviderSurface::AnthropicMessages,
detect_request: |obj, hint| {
// A system-less Anthropic request is shape-identical to OpenAI Chat;
Expand Down
8 changes: 2 additions & 6 deletions crates/core/src/codec/openai_chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use crate::error::{FlowError, Result};
use crate::json::Json;

use super::request::{AnnotatedLlmRequest, GenerationParams, Message, ToolChoice, ToolDefinition};
use super::resolve::{ProviderSurface, SurfaceDescriptor};
use super::resolve::{ProviderSurface, ProviderSurfaceDescriptor};
use super::response::{
AnnotatedLlmResponse, ApiSpecificResponse, FinishReason, RawUsageCost, ResponseToolCall, Usage,
estimate_cost_for_provider, infer_model_provider, provider_reported_cost,
Expand All @@ -27,11 +27,7 @@ use super::traits::{LlmCodec, LlmResponseCodec};
/// Built-in codec for the OpenAI Chat Completions API.
pub struct OpenAIChatCodec;

// ---------------------------------------------------------------------------
// Built-in surface descriptor (codec-owned detection, registered in resolve)
// ---------------------------------------------------------------------------

pub(crate) const SURFACE_DESCRIPTOR: SurfaceDescriptor = SurfaceDescriptor {
pub(crate) const PROVIDER_SURFACE: ProviderSurfaceDescriptor = ProviderSurfaceDescriptor {
surface: ProviderSurface::OpenAIChat,
detect_request: |obj, _| obj.contains_key("messages"),
detect_response: |obj| obj.get("choices").is_some_and(Json::is_array),
Expand Down
8 changes: 2 additions & 6 deletions crates/core/src/codec/openai_responses.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ use super::request::{
AnnotatedLlmRequest, GenerationParams, Message, MessageContent, ToolChoice, ToolChoiceFunction,
ToolChoiceFunctionName, ToolDefinition,
};
use super::resolve::{ProviderSurface, SurfaceDescriptor};
use super::resolve::{ProviderSurface, ProviderSurfaceDescriptor};
use super::response::{
AnnotatedLlmResponse, ApiSpecificResponse, FinishReason, RawUsageCost, ResponseToolCall, Usage,
estimate_cost_for_provider, infer_model_provider, provider_reported_cost,
Expand All @@ -39,11 +39,7 @@ use super::traits::{LlmCodec, LlmResponseCodec};
/// Built-in codec for the OpenAI Responses API.
pub struct OpenAIResponsesCodec;

// ---------------------------------------------------------------------------
// Built-in surface descriptor (codec-owned detection, registered in resolve)
// ---------------------------------------------------------------------------

pub(crate) const SURFACE_DESCRIPTOR: SurfaceDescriptor = SurfaceDescriptor {
pub(crate) const PROVIDER_SURFACE: ProviderSurfaceDescriptor = ProviderSurfaceDescriptor {
surface: ProviderSurface::OpenAIResponses,
detect_request: |obj, _| obj.contains_key("input") || obj.contains_key("instructions"),
detect_response: |obj| {
Expand Down
84 changes: 49 additions & 35 deletions crates/core/src/codec/resolve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,24 +26,37 @@ pub enum ProviderSurface {

/// Request shape detector; the optional `&str` is a provider hint a codec may use
/// to claim an otherwise-ambiguous shape.
type RequestDetector = fn(&serde_json::Map<String, Json>, Option<&str>) -> bool;
type ResponseDetector = fn(&serde_json::Map<String, Json>) -> bool;
type RequestSurfaceDetector = fn(&serde_json::Map<String, Json>, Option<&str>) -> bool;
type ResponseSurfaceDetector = fn(&serde_json::Map<String, Json>) -> bool;

pub(crate) struct SurfaceDescriptor {
/// Built-in provider extraction strategy for one request/response surface.
///
/// The descriptor keeps surface detection next to the codec that owns the
/// schema-specific decode logic while preserving the existing public
/// [`LlmCodec`](super::traits::LlmCodec) and
/// [`LlmResponseCodec`](super::traits::LlmResponseCodec) traits.
/// `decode_response` is the provider response-extraction interface: built-in
/// codecs populate [`AnnotatedLlmResponse`] with model names, finish reasons,
/// tool calls, usage, cost, provider-specific fields, and replayable response
/// data when the source payload supplies them.
pub(crate) struct ProviderSurfaceDescriptor {
pub(crate) surface: ProviderSurface,
pub(crate) detect_request: RequestDetector,
pub(crate) detect_response: ResponseDetector,
pub(crate) detect_request: RequestSurfaceDetector,
pub(crate) detect_response: ResponseSurfaceDetector,
pub(crate) decode_request: fn(&LlmRequest) -> Result<AnnotatedLlmRequest>,
pub(crate) decode_response: fn(&Json) -> Result<AnnotatedLlmResponse>,
}

/// Built-in surfaces in request-detection priority order (first match wins):
/// Responses > Anthropic > Chat. The order is authoritative — a hint-aware
/// detector must stay after any stronger-signal surface it could shadow.
static REGISTRY: &[SurfaceDescriptor] = &[
openai_responses::SURFACE_DESCRIPTOR,
anthropic::SURFACE_DESCRIPTOR,
openai_chat::SURFACE_DESCRIPTOR,
/// Built-in provider surfaces in request-detection priority order.
///
/// First match wins for requests because some shapes overlap. The order is
/// authoritative: a hint-aware detector must stay after any stronger-signal
/// surface it could shadow. Response detection requires exactly one match
/// before decoding.
pub(crate) static BUILTIN_PROVIDER_SURFACES: &[ProviderSurfaceDescriptor] = &[
openai_responses::PROVIDER_SURFACE,
anthropic::PROVIDER_SURFACE,
openai_chat::PROVIDER_SURFACE,
];

/// Detect the request surface from a raw request body by top-level key.
Expand All @@ -68,48 +81,49 @@ pub fn detect_request_surface_with_hint(
body: &Json,
provider_hint: Option<&str>,
) -> Option<ProviderSurface> {
request_descriptor(body, provider_hint).map(|descriptor| descriptor.surface)
}

/// Detect the response surface from a raw provider response, classifying only
/// when exactly one built-in shape matches (the built-in codecs accept minimal
/// objects, so decode success alone is not a reliable classifier).
#[must_use]
pub fn detect_response_surface(raw: &Json) -> Option<ProviderSurface> {
response_descriptor(raw).map(|descriptor| descriptor.surface)
}

fn request_descriptor(
body: &Json,
provider_hint: Option<&str>,
) -> Option<&'static ProviderSurfaceDescriptor> {
let obj = body.as_object()?;
REGISTRY
BUILTIN_PROVIDER_SURFACES
.iter()
.find(|d| (d.detect_request)(obj, provider_hint))
.map(|d| d.surface)
.find(|descriptor| (descriptor.detect_request)(obj, provider_hint))
}

/// Classify a response object to exactly one built-in surface descriptor: the
/// single source of truth shared by [`detect_response_surface`] and
/// [`normalize_response`]. Zero or multiple matches yield `None` (the built-in
/// codecs accept minimal objects, so decode success alone is not a reliable
/// classifier).
fn detect_response_descriptor(
obj: &serde_json::Map<String, Json>,
) -> Option<&'static SurfaceDescriptor> {
let mut matches = REGISTRY.iter().filter(|d| (d.detect_response)(obj));
fn response_descriptor(raw: &Json) -> Option<&'static ProviderSurfaceDescriptor> {
let obj = raw.as_object()?;
let mut matches = BUILTIN_PROVIDER_SURFACES
.iter()
.filter(|descriptor| (descriptor.detect_response)(obj));
match (matches.next(), matches.next()) {
(Some(descriptor), None) => Some(descriptor),
_ => None,
}
}

/// Detect the response surface from a raw provider response, classifying only
/// when exactly one built-in shape matches (the built-in codecs accept minimal
/// objects, so decode success alone is not a reliable classifier).
#[must_use]
pub fn detect_response_surface(raw: &Json) -> Option<ProviderSurface> {
detect_response_descriptor(raw.as_object()?).map(|d| d.surface)
}

/// Best-effort decode of a raw request into [`AnnotatedLlmRequest`] (fail-open).
#[must_use]
pub fn normalize_request(request: &LlmRequest) -> Option<AnnotatedLlmRequest> {
let obj = request.content.as_object()?;
let descriptor = REGISTRY.iter().find(|d| (d.detect_request)(obj, None))?;
let descriptor = request_descriptor(&request.content, None)?;
(descriptor.decode_request)(request).ok()
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/// Best-effort decode of a raw response into [`AnnotatedLlmResponse`] (fail-open).
#[must_use]
pub fn normalize_response(raw: &Json) -> Option<AnnotatedLlmResponse> {
let descriptor = detect_response_descriptor(raw.as_object()?)?;
let descriptor = response_descriptor(raw)?;
(descriptor.decode_response)(raw).ok()
}

Expand Down
Loading
Loading