Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
57 changes: 44 additions & 13 deletions crates/adaptive/src/acg/ir_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,19 +35,35 @@ use crate::acg::prompt_ir::{
pub fn build_prompt_ir(request: &AnnotatedLlmRequest) -> Result<PromptIR> {
let mut blocks: Vec<PromptBlock> = Vec::new();
let mut sequence_index: u32 = 0;
let mut inserted_tool_blocks = false;
let mut inserted_scaffold_blocks = false;
let structured_output = request
.extra
.get("response_format")
.filter(|value| !value.is_null())
.map(canonicalize_value)
.transpose()?;

for message in &request.messages {
if should_insert_tool_blocks_before_message(inserted_tool_blocks, request, message) {
if !inserted_scaffold_blocks && !matches!(message, Message::System { .. }) {
append_tool_schema_blocks(&mut blocks, &mut sequence_index, request.tools.as_deref())?;
inserted_tool_blocks = true;
append_structured_output_block(
&mut blocks,
&mut sequence_index,
structured_output.as_deref(),
);
inserted_scaffold_blocks = true;
}

append_message_blocks(&mut blocks, &mut sequence_index, message)?;
}

if !inserted_tool_blocks {
if !inserted_scaffold_blocks {
append_tool_schema_blocks(&mut blocks, &mut sequence_index, request.tools.as_deref())?;
append_structured_output_block(
&mut blocks,
&mut sequence_index,
structured_output.as_deref(),
);
}

let tool_schema_hashes = match &request.tools {
Expand All @@ -60,20 +76,12 @@ pub fn build_prompt_ir(request: &AnnotatedLlmRequest) -> Result<PromptIR> {
ir_id: Uuid::new_v4(),
blocks,
tool_schema_hashes,
structured_output_schema_id: None,
structured_output_schema_id: structured_output.as_deref().map(sha256_hex),
source_request_hash,
created_at: Utc::now(),
})
}

fn should_insert_tool_blocks_before_message(
inserted_tool_blocks: bool,
request: &AnnotatedLlmRequest,
message: &Message,
) -> bool {
!inserted_tool_blocks && !matches!(message, Message::System { .. }) && request.tools.is_some()
}

fn append_message_blocks(
blocks: &mut Vec<PromptBlock>,
sequence_index: &mut u32,
Expand Down Expand Up @@ -252,6 +260,29 @@ fn append_tool_schema_blocks(
Ok(())
}

fn append_structured_output_block(
blocks: &mut Vec<PromptBlock>,
seq: &mut u32,
response_format: Option<&str>,
) {
let Some(content) = response_format else {
return;
};

let index = *seq;
*seq += 1;
blocks.push(PromptBlock {
span_id: generate_span_id(PromptRole::System, index, Some("structured-output")),
sequence_index: index,
role: PromptRole::System,
content: content.to_string(),
content_type: BlockContentType::StructuredOutput,
provenance: ProvenanceLabel::System,
sensitivity: SensitivityLabel::default(),
token_metadata: None,
});
}

fn build_tool_schema_block(seq: &mut u32, tool: &ToolDefinition) -> Result<PromptBlock> {
let tool_value = serde_json::to_value(tool)?;
let canonical = canonicalize_value(&tool_value)?;
Expand Down
101 changes: 101 additions & 0 deletions crates/adaptive/src/acg/stability.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ pub struct StabilityAnalysisResult {
pub scores: Vec<BlockStabilityScore>,
/// Number of leading blocks that were classified as stable.
pub stable_prefix_length: usize,
/// Fingerprint of the dominant stable prefix. Generic analysis hashes the
/// prompt IR; ACG profile persistence additionally binds it to the learning
/// key and, beyond the leading scaffold, the full source request.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stable_prefix_fingerprint: Option<String>,
/// Total number of observations included in the analysis.
pub total_observations: u32,
}
Expand Down Expand Up @@ -69,6 +74,7 @@ pub fn analyze_stability(
return StabilityAnalysisResult {
scores: Vec::new(),
stable_prefix_length: 0,
stable_prefix_fingerprint: None,
total_observations: 0,
};
}
Expand All @@ -89,14 +95,109 @@ pub fn analyze_stability(
let scores: Vec<BlockStabilityScore> =
indexed_scores.into_iter().map(|(_, score)| score).collect();
let stable_prefix_length = find_stable_prefix_length(&scores);
let stable_prefix_fingerprint = observations
.iter()
.filter_map(|observation| prompt_prefix_fingerprint(observation, stable_prefix_length))
.fold(HashMap::new(), |mut counts, fingerprint| {
*counts.entry(fingerprint).or_insert(0_u32) += 1;
counts
})
.into_iter()
.max_by(|(left_hash, left_count), (right_hash, right_count)| {
left_count
.cmp(right_count)
.then_with(|| right_hash.cmp(left_hash))
})
.map(|(fingerprint, _)| fingerprint);

StabilityAnalysisResult {
scores,
stable_prefix_length,
stable_prefix_fingerprint,
total_observations,
}
}

pub(crate) fn prompt_prefix_fingerprint(
observation: &PromptIR,
prefix_length: usize,
) -> Option<String> {
if prefix_length == 0 || observation.blocks.len() < prefix_length {
return None;
}

let prefix = observation
.blocks
.iter()
.take(prefix_length)
.map(|block| {
serde_json::to_string(&(
&block.span_id,
block.role,
block.content_type,
&block.content,
))
})
.collect::<std::result::Result<Vec<_>, _>>()
.ok()?
.join("\n");
Some(sha256_hex(&prefix))
}

pub(crate) fn profile_prefix_fingerprint(
observation: &PromptIR,
prefix_length: usize,
learning_key: &str,
) -> Option<String> {
let prefix_fingerprint = prompt_prefix_fingerprint(observation, prefix_length)?;
let scaffold_length = observation
.blocks
.iter()
.take_while(|block| {
block.role == crate::acg::prompt_ir::PromptRole::System
|| matches!(
block.content_type,
crate::acg::prompt_ir::BlockContentType::ToolSchema
| crate::acg::prompt_ir::BlockContentType::StructuredOutput
)
})
.count();
// Conservatively bind deeper prefixes to the complete request because the
// normalized IR does not retain a lossless provider-prefix representation.
let request_fingerprint = if prefix_length > scaffold_length {
observation.source_request_hash.as_deref()?
} else {
"stable-scaffold"
};

Some(sha256_hex(
&[learning_key, &prefix_fingerprint, request_fingerprint].join("\n"),
))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

pub(crate) fn dominant_profile_prefix_fingerprint(
observations: &[PromptIR],
prefix_length: usize,
learning_key: &str,
) -> Option<String> {
observations
.iter()
.filter_map(|observation| {
profile_prefix_fingerprint(observation, prefix_length, learning_key)
})
.fold(HashMap::new(), |mut counts, fingerprint| {
*counts.entry(fingerprint).or_insert(0_u32) += 1;
counts
})
.into_iter()
.max_by(|(left_hash, left_count), (right_hash, right_count)| {
left_count
.cmp(right_count)
.then_with(|| right_hash.cmp(left_hash))
})
.map(|(fingerprint, _)| fingerprint)
}

fn record_observation(observation: &PromptIR, span_map: &mut HashMap<SpanId, SpanObservations>) {
let mut seen_in_observation: HashSet<SpanId> = HashSet::new();

Expand Down
28 changes: 28 additions & 0 deletions crates/adaptive/src/acg_component.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,34 @@ fn build_intent_bundle(
return None;
}

let learning_key = derive_acg_learning_key(agent_id, annotated_request);
let live_fingerprint = crate::acg::stability::profile_prefix_fingerprint(
prompt_ir,
stability.stable_prefix_length,
&learning_key,
);
let Some(stored_fingerprint) = stability.stable_prefix_fingerprint.as_ref() else {
acg_debug::emit(
"build_intent_bundle_skipped",
json!({"reason": "stable_prefix_fingerprint_missing"}),
);
return None;
};
let Some(live_fingerprint) = live_fingerprint.as_ref() else {
acg_debug::emit(
"build_intent_bundle_skipped",
json!({"reason": "stable_prefix_fingerprint_unavailable"}),
);
return None;
};
if stored_fingerprint != live_fingerprint {
acg_debug::emit(
"build_intent_bundle_skipped",
json!({"reason": "stable_prefix_fingerprint_mismatch"}),
);
return None;
}

let toolset_hash = annotated_request
.tools
.as_ref()
Expand Down
8 changes: 7 additions & 1 deletion crates/adaptive/src/acg_learner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,13 @@ impl Learner for AcgLearner {
.store_observations(&profile_key, &observations_vec)
.await?;

let stability_result = analyze_stability(&observations_vec, &self.thresholds);
let mut stability_result = analyze_stability(&observations_vec, &self.thresholds);
stability_result.stable_prefix_fingerprint =
crate::acg::stability::dominant_profile_prefix_fingerprint(
&observations_vec,
stability_result.stable_prefix_length,
&profile_key,
);
backend
.store_stability(&profile_key, &stability_result)
.await?;
Expand Down
53 changes: 46 additions & 7 deletions crates/adaptive/src/acg_profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ struct AcgKeyParts<'a> {
model: &'a str,
system_hash: String,
tool_hash: String,
response_format_hash: Option<String>,
has_stable_scaffold: bool,
}

/// Derive the stable ACG learning key used to bucket observations and hot-cache state.
Expand All @@ -28,12 +30,20 @@ pub(crate) fn derive_acg_learning_key(
annotated_request: &AnnotatedLlmRequest,
) -> String {
let parts = derive_key_parts(annotated_request);
let seed_fingerprint = learning_seed_fingerprint(annotated_request);
let seed_hash = short_hash(&seed_fingerprint);
format!(
let seed_fingerprint =
(!parts.has_stable_scaffold).then(|| learning_seed_fingerprint(annotated_request));
let seed_hash = seed_fingerprint
.as_deref()
.map(short_hash)
.unwrap_or("stable-scaffold");
let key = format!(
"{agent_id}::model={}::seed={seed_hash}::system={}::tools={}",
parts.model, parts.system_hash, parts.tool_hash
)
);
parts
.response_format_hash
.map(|hash| format!("{key}::response_format={hash}"))
.unwrap_or(key)
}

/// Derive the exact ACG profile key used for diagnostics and debug output.
Expand All @@ -58,18 +68,38 @@ pub(crate) fn derive_acg_profile_key(
.join(".");
format!(
"{agent_id}::model={}::roles={role_signature}::system={}::anchor={}::tools={}",
parts.model, parts.system_hash, anchor_hash, parts.tool_hash
parts.model,
short_hash(&parts.system_hash),
anchor_hash,
short_hash(&parts.tool_hash)
)
}

fn derive_key_parts(annotated_request: &AnnotatedLlmRequest) -> AcgKeyParts<'_> {
let system_fingerprint = system_prompt_fingerprint(annotated_request);
let tool_fingerprint = tool_schema_fingerprint(annotated_request.tools.as_deref());
let response_format_fingerprint = response_format_fingerprint(annotated_request);
let has_stable_scaffold = system_fingerprint != "no-system"
|| annotated_request
.tools
.as_ref()
.is_some_and(|tools| !tools.is_empty())
|| response_format_fingerprint.is_some();

AcgKeyParts {
model: annotated_request.model.as_deref().unwrap_or("unknown"),
system_hash: short_hash(&system_fingerprint).to_string(),
tool_hash: short_hash(&tool_fingerprint).to_string(),
system_hash: if has_stable_scaffold {
system_fingerprint
} else {
short_hash(&system_fingerprint).to_string()
},
tool_hash: if has_stable_scaffold {
tool_fingerprint
} else {
short_hash(&tool_fingerprint).to_string()
},
response_format_hash: response_format_fingerprint,
has_stable_scaffold,
}
}

Expand Down Expand Up @@ -183,6 +213,15 @@ fn tool_schema_fingerprint(tools: Option<&[ToolDefinition]>) -> String {
}
}

fn response_format_fingerprint(annotated_request: &AnnotatedLlmRequest) -> Option<String> {
annotated_request
.extra
.get("response_format")
.filter(|value| !value.is_null())
.and_then(|value| canonicalize_value(value).ok())
.map(|value| sha256_hex(&value))
}

fn extract_text(content: &MessageContent) -> String {
match content {
MessageContent::Text(text) => text.clone(),
Expand Down
Loading