Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
299 changes: 201 additions & 98 deletions crates/cli/src/adapters/mod.rs

Large diffs are not rendered by default.

58 changes: 50 additions & 8 deletions crates/cli/src/alignment/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,50 @@ 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 BuiltinProviderRequestExtractor;

static BUILTIN_PROVIDER_REQUEST_EXTRACTOR: BuiltinProviderRequestExtractor =
BuiltinProviderRequestExtractor;

impl ProviderRequestExtractor for BuiltinProviderRequestExtractor {
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> {
// Keep this narrower than the codec hint matcher: only the real Messages
// route carries a user turn body, while Anthropic count-token traffic is
// a management/probe path that should not create a synthetic turn.
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 +606,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))
BUILTIN_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 +622,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 }))
BUILTIN_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
5 changes: 3 additions & 2 deletions crates/cli/src/gateway.rs
Original file line number Diff line number Diff line change
Expand Up @@ -955,8 +955,9 @@ impl ProviderRoute {
}
}

// Returns the provider route name recorded in LLM event metadata. These names split OpenAI API
// variants because their request/response schemas differ even when they share a base URL.
// Returns the provider route name recorded on managed LLM events. These names split OpenAI API
// variants because their request/response schemas differ even when they share a base URL, and
// they double as codec hints for ambiguous provider request shapes.
const fn name(self) -> &'static str {
match self {
Self::OpenAiResponses => "openai.responses",
Expand Down
98 changes: 94 additions & 4 deletions crates/cli/tests/coverage/adapters_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,13 @@ fn maps_claude_canonical_tool_payload() {
assert_eq!(event.tool_call_id, "toolu-1");
assert_eq!(event.tool_name, "Read");
assert_eq!(event.arguments, json!({ "file_path": "README.md" }));
assert!(event.metadata.get("transcript_path").is_none());
assert!(event.metadata.get("cwd").is_none());
assert_eq!(
event.metadata["transcript_path"],
event.payload["transcript_path"],
json!("/tmp/transcript.jsonl")
);
assert_eq!(event.payload["cwd"], json!("/workspace"));
}
event => panic!("unexpected event: {event:?}"),
}
Expand Down Expand Up @@ -231,6 +234,91 @@ fn adapter_string_lookup_accepts_scalar_values_only() {
assert_eq!(string_at(&payload, &["object"]), None);
}

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

assert_eq!(
BUILTIN_AGENT_PAYLOAD_EXTRACTOR.session_id(&payload, &headers),
None
);
assert_eq!(BUILTIN_AGENT_PAYLOAD_EXTRACTOR.event_name(&payload), None);
assert_eq!(
BUILTIN_AGENT_PAYLOAD_EXTRACTOR.subagent_id(&payload, &headers),
None
);
assert_eq!(
BUILTIN_AGENT_PAYLOAD_EXTRACTOR.llm_hint(&payload, &headers),
ExtractedLlmHint::default()
);
assert_eq!(
BUILTIN_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 builtin_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!(
BUILTIN_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!(
BUILTIN_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 @@ -251,8 +339,10 @@ fn maps_cursor_subagent_and_permission_response() {
NormalizedEvent::ToolStarted(event) => {
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("project_dir").is_none());
assert!(event.metadata.get("user_email").is_none());
assert_eq!(event.payload["project_dir"], json!("/repo"));
assert_eq!(event.payload["user_email"], json!("dev@example.com"));
}
event => panic!("unexpected event: {event:?}"),
}
Expand Down Expand Up @@ -727,7 +817,7 @@ fn normalizes_mark_style_events_and_header_session_ids() {
}
assert_eq!(session_id, "header-session");
assert_eq!(metadata["model"], json!("model-a"));
assert_eq!(metadata["cwd"], json!("/repo"));
assert!(metadata.get("cwd").is_none());
Comment thread
coderabbitai[bot] marked this conversation as resolved.
assert_eq!(metadata["gateway_config_profile"], json!("coverage"));
}
}
Expand Down
5 changes: 4 additions & 1 deletion crates/core/src/api/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,10 @@ impl EventNormalizationExt for Event {
return Some(Cow::Borrowed(annotated.as_ref()));
}
let request: LlmRequest = serde_json::from_value(self.input()?.clone()).ok()?;
resolve::normalize_request(&request).map(Cow::Owned)
// Managed LLM events use the provider route as the event name (for
// example, "anthropic.messages"), which doubles as the codec hint for
// shape-identical request bodies.
resolve::normalize_request_with_hint(&request, Some(self.name())).map(Cow::Owned)
Comment thread
yczhang-nv marked this conversation as resolved.
}

fn normalized_llm_response(&self) -> Option<Cow<'_, AnnotatedLlmResponse>> {
Expand Down
7 changes: 6 additions & 1 deletion crates/core/src/api/llm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ pub struct LlmHandle {
#[builder(default = Utc::now())]
pub started_at: DateTime<Utc>,
/// Provider or logical call name recorded on lifecycle events.
///
/// Gateway-managed provider calls use provider route names such as
/// `anthropic.messages`; event normalization may reuse those route names as
/// codec hints when raw request shapes overlap across providers.
#[builder(setter(into))]
pub name: String,
/// Optional application payload stored on the handle.
Expand All @@ -64,7 +68,8 @@ pub struct LlmHandle {
#[derive(Debug, Clone, TypedBuilder)]
#[builder(field_defaults(setter(strip_option(ignore_invalid, fallback_suffix = "_opt"))))]
pub struct CreateLlmHandleParams<'a> {
/// Logical provider or model family name.
/// Logical provider or model family name. Gateway-managed provider calls
/// should pass the provider route name, for example `anthropic.messages`.
pub name: &'a str,
/// Optional parent scope UUID.
#[builder(default)]
Expand Down
4 changes: 3 additions & 1 deletion crates/core/src/api/runtime/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -416,7 +416,9 @@ impl NemoRelayContextState {
/// Create a new LLM handle.
///
/// # Parameters
/// - `name`: Logical provider or model family name.
/// - `name`: Logical provider or model family name. Gateway-managed LLM
/// calls use provider route names such as `anthropic.messages`, which
/// become the emitted event name.
/// - `parent_uuid`: Optional parent scope UUID.
/// - `attributes`: LLM attribute bitflags.
/// - `data`: Optional application payload stored on the handle.
Expand Down
15 changes: 7 additions & 8 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,16 +40,15 @@ 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;
// the "anthropic" hint disambiguates it.
obj.contains_key("system") || (hint == Some("anthropic") && obj.contains_key("messages"))
// an Anthropic provider hint disambiguates it.
let hinted_anthropic = hint.is_some_and(|hint_value| {
hint_value == "anthropic" || hint_value.starts_with("anthropic.")
});
obj.contains_key("system") || (hinted_anthropic && obj.contains_key("messages"))
},
detect_response: |obj| {
obj.get("type").and_then(Json::as_str) == Some("message")
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
Loading
Loading