Skip to content

Commit c6c52a9

Browse files
committed
feat(code): delegate intent detection to AHP harness server
Remove local keyword fallback - if AHP harness doesn't register IntentDetection, context perception is skipped entirely. - Add IntentDetection hook event type and IntentDetectionEvent payload - Add IntentDetection to AHP executor mapping (event, decision, blocking) - Add fire_intent_detection() method to agent - Add IntentDetectionResult and TargetHints structs - Add detect_language_hint() for multi-language support - Add build_pre_context_perception_from_intent() helper - Modify agent loop to fire IntentDetection on every prompt first - Export IntentDetectionEvent from hooks module - Remove detect_context_perception_intent() fallback AHP server now has exclusive control over intent detection.
1 parent 4de4eb6 commit c6c52a9

4 files changed

Lines changed: 212 additions & 173 deletions

File tree

core/src/agent.rs

Lines changed: 156 additions & 165 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@ use crate::context::{ContextProvider, ContextQuery, ContextResult};
1717
use crate::hitl::ConfirmationProvider;
1818
use crate::hooks::{
1919
ErrorType, GenerateEndEvent, GenerateStartEvent, HookEvent, HookExecutor, HookResult,
20-
OnErrorEvent, PostResponseEvent, PostToolUseEvent, PreContextPerceptionEvent, PrePromptEvent,
21-
PreToolUseEvent, TokenUsageInfo, ToolCallInfo, ToolResultData,
20+
IntentDetectionEvent, OnErrorEvent, PostResponseEvent, PostToolUseEvent,
21+
PreContextPerceptionEvent, PrePromptEvent, PreToolUseEvent, TokenUsageInfo, ToolCallInfo,
22+
ToolResultData,
2223
};
2324
use crate::llm::{LlmClient, LlmResponse, Message, TokenUsage, ToolDefinition};
2425
use crate::permissions::{PermissionChecker, PermissionDecision};
@@ -767,6 +768,86 @@ fn detect_domain_from_prompt(prompt: &str) -> String {
767768
}
768769
}
769770

771+
/// Result from IntentDetection harness
772+
#[derive(Debug, Clone, Serialize, Deserialize)]
773+
pub struct IntentDetectionResult {
774+
/// Detected intent: "locate" | "understand" | "retrieve" | "explore" | "reason" | "validate" | "compare" | "track"
775+
pub detected_intent: String,
776+
/// Confidence score 0.0 - 1.0
777+
pub confidence: f32,
778+
/// Optional target hints from the harness
779+
#[serde(skip_serializing_if = "Option::is_none")]
780+
pub target_hints: Option<TargetHints>,
781+
}
782+
783+
/// Target hints from IntentDetection harness
784+
#[derive(Debug, Clone, Serialize, Deserialize)]
785+
pub struct TargetHints {
786+
#[serde(skip_serializing_if = "Option::is_none")]
787+
pub target_type: Option<String>,
788+
#[serde(skip_serializing_if = "Option::is_none")]
789+
pub target_name: Option<String>,
790+
#[serde(skip_serializing_if = "Option::is_none")]
791+
pub domain: Option<String>,
792+
}
793+
794+
/// Detect language hint from prompt characters.
795+
fn detect_language_hint(prompt: &str) -> Option<String> {
796+
// Check for Chinese characters
797+
if prompt.chars().any(|c| '\u{4e00}' <= c && c <= '\u{9fff}') {
798+
return Some("zh".to_string());
799+
}
800+
// Check for Japanese characters (Hiragana, Katakana, or CJK unified ideographs outside Chinese range)
801+
if prompt
802+
.chars()
803+
.any(|c| ('\u{3040}' <= c && c <= '\u{309f}') || ('\u{30a0}' <= c && c <= '\u{30ff}'))
804+
{
805+
return Some("ja".to_string());
806+
}
807+
// Check for Korean characters
808+
if prompt.chars().any(|c| '\u{ac00}' <= c && c <= '\u{d7af}') {
809+
return Some("ko".to_string());
810+
}
811+
// Check for Arabic
812+
if prompt.chars().any(|c| '\u{0600}' <= c && c <= '\u{06ff}') {
813+
return Some("ar".to_string());
814+
}
815+
// Check for Russian/Cyrillic
816+
if prompt.chars().any(|c| '\u{0400}' <= c && c <= '\u{04ff}') {
817+
return Some("ru".to_string());
818+
}
819+
None
820+
}
821+
822+
/// Build PreContextPerceptionEvent from IntentDetection result.
823+
fn build_pre_context_perception_from_intent(
824+
result: IntentDetectionResult,
825+
prompt: &str,
826+
session_id: &str,
827+
workspace: &str,
828+
) -> PreContextPerceptionEvent {
829+
let target_hints = result.target_hints;
830+
PreContextPerceptionEvent {
831+
session_id: session_id.to_string(),
832+
intent: result.detected_intent,
833+
target_type: target_hints
834+
.as_ref()
835+
.and_then(|h| h.target_type.clone())
836+
.unwrap_or_else(|| "unknown".to_string()),
837+
target_name: target_hints
838+
.as_ref()
839+
.and_then(|h| h.target_name.clone())
840+
.unwrap_or_else(|| extract_target_name_from_prompt(prompt, &[])),
841+
domain: target_hints
842+
.as_ref()
843+
.and_then(|h| h.domain.clone())
844+
.unwrap_or_else(|| detect_domain_from_prompt(prompt)),
845+
query: Some(prompt.to_string()),
846+
working_directory: workspace.to_string(),
847+
urgency: "normal".to_string(),
848+
}
849+
}
850+
770851
/// Rough token estimation (~4 chars per token for English/code).
771852
#[cfg(feature = "ahp")]
772853
fn estimate_tokens(text: &str) -> usize {
@@ -1495,157 +1576,55 @@ impl AgentLoop {
14951576
None
14961577
}
14971578

1498-
/// Detect if context perception is needed based on user prompt.
1579+
/// Fire PreContextPerception hook and wait for harness decision.
1580+
async fn fire_pre_context_perception(&self, event: &PreContextPerceptionEvent) -> HookResult {
1581+
if let Some(he) = &self.config.hook_engine {
1582+
let hook_event = HookEvent::PreContextPerception(event.clone());
1583+
he.fire(&hook_event).await
1584+
} else {
1585+
HookResult::continue_()
1586+
}
1587+
}
1588+
1589+
/// Fire IntentDetection hook and wait for harness decision.
14991590
///
1500-
/// Returns `Some(PreContextPerceptionEvent)` if the prompt suggests the model
1501-
/// needs workspace knowledge (finding files, understanding code, etc.).
1502-
pub fn detect_context_perception_intent(
1591+
/// This is called on every prompt to detect user intent via the AHP harness.
1592+
/// Returns the detected intent if the harness provides one, or None if blocked/failed.
1593+
async fn fire_intent_detection(
15031594
&self,
15041595
prompt: &str,
15051596
session_id: &str,
15061597
workspace: &str,
1507-
) -> Option<PreContextPerceptionEvent> {
1508-
let lower = prompt.to_lowercase();
1509-
1510-
// Pattern matching for different intents that suggest context perception is needed
1511-
let intents: &[(&[&str], &str)] = &[
1512-
// Locate: finding files, functions, resources
1513-
(
1514-
&[
1515-
"where is",
1516-
"where are",
1517-
"find the file",
1518-
"find all",
1519-
"find files",
1520-
"who wrote",
1521-
"locate",
1522-
"search for",
1523-
"look for",
1524-
"search",
1525-
],
1526-
"locate",
1527-
),
1528-
// Understand: explaining how something works
1529-
(
1530-
&[
1531-
"how does",
1532-
"what does",
1533-
"explain",
1534-
"understand",
1535-
"what is this",
1536-
"how does this work",
1537-
],
1538-
"understand",
1539-
),
1540-
// Retrieve: recalling from memory/past
1541-
(
1542-
&[
1543-
"remember",
1544-
"earlier",
1545-
"before",
1546-
"previously",
1547-
"last time",
1548-
"past",
1549-
"previous",
1550-
],
1551-
"retrieve",
1552-
),
1553-
// Explore: understanding structure
1554-
(
1555-
&[
1556-
"how is organized",
1557-
"project structure",
1558-
"what files",
1559-
"show me the structure",
1560-
"explore",
1561-
],
1562-
"explore",
1563-
),
1564-
// Reason: asking why/causality
1565-
(
1566-
&[
1567-
"why did",
1568-
"why is",
1569-
"cause",
1570-
"reason",
1571-
"what happened",
1572-
"why does",
1573-
],
1574-
"reason",
1575-
),
1576-
// Validate: checking correctness
1577-
(
1578-
&["is this correct", "verify", "validate", "check if", "debug"],
1579-
"validate",
1580-
),
1581-
// Compare: comparing things
1582-
(
1583-
&[
1584-
"difference between",
1585-
"compare",
1586-
"versus",
1587-
" vs ",
1588-
"different from",
1589-
],
1590-
"compare",
1591-
),
1592-
// Track: status/history
1593-
(
1594-
&[
1595-
"status",
1596-
"progress",
1597-
"how far",
1598-
"history",
1599-
"what's the current",
1600-
],
1601-
"track",
1602-
),
1603-
];
1598+
) -> Option<IntentDetectionResult> {
1599+
let event = IntentDetectionEvent {
1600+
session_id: session_id.to_string(),
1601+
prompt: prompt.to_string(),
1602+
workspace: workspace.to_string(),
1603+
language_hint: detect_language_hint(prompt),
1604+
};
16041605

1605-
// Detect target type from keywords
1606-
let target_type = if lower.contains("function") || lower.contains("method") {
1607-
"function"
1608-
} else if lower.contains("file") || lower.contains("config") {
1609-
"file"
1610-
} else if lower.contains("class") {
1611-
"entity"
1612-
} else if lower.contains("module") || lower.contains("package") {
1613-
"module"
1614-
} else if lower.contains("test") {
1615-
"test"
1606+
let hook_result = if let Some(he) = &self.config.hook_engine {
1607+
let hook_event = HookEvent::IntentDetection(event);
1608+
he.fire(&hook_event).await
16161609
} else {
1617-
"unknown"
1610+
return None;
16181611
};
16191612

1620-
// Find matching intent
1621-
let matched_intent = intents
1622-
.iter()
1623-
.find(|(patterns, _)| patterns.iter().any(|p| lower.contains(p)));
1624-
1625-
matched_intent.map(|(patterns, intent)| {
1626-
// Extract target name if possible (simplified extraction)
1627-
let target_name = extract_target_name_from_prompt(prompt, patterns);
1628-
1629-
PreContextPerceptionEvent {
1630-
session_id: session_id.to_string(),
1631-
intent: intent.to_string(),
1632-
target_type: target_type.to_string(),
1633-
target_name,
1634-
domain: detect_domain_from_prompt(prompt),
1635-
query: Some(prompt.to_string()),
1636-
working_directory: workspace.to_string(),
1637-
urgency: "normal".to_string(),
1613+
match hook_result {
1614+
HookResult::Continue(Some(modified)) => {
1615+
// Parse the intent detection result
1616+
if let Ok(result) = serde_json::from_value::<IntentDetectionResult>(modified) {
1617+
Some(result)
1618+
} else {
1619+
None
1620+
}
16381621
}
1639-
})
1640-
}
1641-
1642-
/// Fire PreContextPerception hook and wait for harness decision.
1643-
async fn fire_pre_context_perception(&self, event: &PreContextPerceptionEvent) -> HookResult {
1644-
if let Some(he) = &self.config.hook_engine {
1645-
let hook_event = HookEvent::PreContextPerception(event.clone());
1646-
he.fire(&hook_event).await
1647-
} else {
1648-
HookResult::continue_()
1622+
HookResult::Block(_) => {
1623+
// Harness blocked intent detection - use fallback
1624+
tracing::info!("AHP harness blocked intent detection");
1625+
None
1626+
}
1627+
_ => None,
16491628
}
16501629
}
16511630

@@ -2424,22 +2403,35 @@ impl AgentLoop {
24242403
};
24252404

24262405
// Resolve context from providers on first turn (before adding user message)
2427-
// Intent-driven: detect context perception need first, then optionally fire AHP hook
2406+
// Intent-driven: detect context perception need first via AHP harness, then fire hook
24282407
let workspace = self.tool_context.workspace.display().to_string();
2408+
let session_id_str = session_id.unwrap_or("");
24292409
let context_results = if !self.config.context_providers.is_empty() {
2430-
// Step 1: Detect if context perception is needed based on prompt
2431-
let needs_context = self.detect_context_perception_intent(
2432-
effective_prompt,
2433-
session_id.unwrap_or(""),
2434-
&workspace,
2435-
);
2410+
// Step 1: Fire IntentDetection harness point on EVERY prompt
2411+
let harness_intent = self
2412+
.fire_intent_detection(effective_prompt, &session_id_str, &workspace)
2413+
.await;
24362414

2437-
if let Some(perception_event) = needs_context {
2438-
// Step 2: Fire PreContextPerception hook to get harness decision
2415+
// Step 2: If harness returned an intent, use it; otherwise skip context perception
2416+
if let Some(detected) = harness_intent {
2417+
tracing::info!(
2418+
intent = %detected.detected_intent,
2419+
confidence = %detected.confidence,
2420+
"Intent detected from AHP harness"
2421+
);
2422+
2423+
let perception_event = build_pre_context_perception_from_intent(
2424+
detected,
2425+
effective_prompt,
2426+
&session_id_str,
2427+
&workspace,
2428+
);
2429+
2430+
// Step 3: Fire PreContextPerception hook to get harness decision
24392431
tracing::info!(
24402432
intent = %perception_event.intent,
24412433
target_type = %perception_event.target_type,
2442-
"Context perception intent detected, firing AHP hook"
2434+
"Firing PreContextPerception hook"
24432435
);
24442436

24452437
let hook_result = self.fire_pre_context_perception(&perception_event).await;
@@ -2458,33 +2450,32 @@ impl AgentLoop {
24582450
);
24592451
self.apply_injected_context(injected)
24602452
} else {
2461-
// Fall back to normal providers if parsing fails
2462-
tracing::warn!(
2463-
"Failed to parse injected context, falling back to providers"
2464-
);
2465-
self.resolve_context(effective_prompt, session_id).await
2453+
// Failed to parse, skip context injection
2454+
tracing::warn!("Failed to parse injected context, skipping");
2455+
Vec::new()
24662456
}
24672457
}
24682458
#[cfg(not(feature = "ahp"))]
24692459
{
2470-
// Without AHP, fall back to normal providers
2471-
let _ = modified_context; // suppress unused warning
2460+
let _ = modified_context;
24722461
self.resolve_context(effective_prompt, session_id).await
24732462
}
24742463
}
24752464
HookResult::Block(_) => {
2476-
// Harness blocked context injection - skip
24772465
tracing::info!("AHP harness blocked context injection");
24782466
Vec::new()
24792467
}
24802468
_ => {
2481-
// No modification or unknown result, proceed with normal providers
2482-
self.resolve_context(effective_prompt, session_id).await
2469+
// No modification, skip context injection
2470+
Vec::new()
24832471
}
24842472
}
24852473
} else {
2486-
// No intent detected, proceed with normal providers
2487-
self.resolve_context(effective_prompt, session_id).await
2474+
// No harness registered for IntentDetection - skip context perception entirely
2475+
tracing::debug!(
2476+
"No IntentDetection harness registered, skipping context perception"
2477+
);
2478+
Vec::new()
24882479
}
24892480
} else {
24902481
Vec::new()

0 commit comments

Comments
 (0)