Skip to content

Commit 0e9a5a6

Browse files
authored
feat(orchestrator): ExitClassifier with KG-boosted agent exit classification (#758)
feat(orchestrator): ExitClassifier with KG-boosted agent exit classification
2 parents 0234a0a + 4d2f2b2 commit 0e9a5a6

15 files changed

Lines changed: 1212 additions & 42 deletions

File tree

Cargo.lock

Lines changed: 27 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ exclude = [
1515
"desktop/src-tauri",
1616
# Planned future use, not needed for workspace builds
1717
"crates/terraphim_build_args",
18-
"crates/terraphim-markdown-parser",
1918
# Unused haystack providers (kept for future integration)
2019
"crates/haystack_atlassian",
2120
"crates/haystack_discourse",

crates/terraphim-markdown-parser/src/lib.rs

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,52 @@ use ulid::Ulid;
1010

1111
pub const TERRAPHIM_BLOCK_ID_PREFIX: &str = "terraphim:block-id:";
1212

13+
/// Extract the first H1 heading from markdown content using AST parsing.
14+
///
15+
/// Returns the heading text with original case preserved, or `None` if no
16+
/// `# Heading` is found. Only matches depth-1 headings (`#`, not `##`).
17+
pub fn extract_first_heading(content: &str) -> Option<String> {
18+
let ast = markdown::to_mdast(content, &ParseOptions::gfm()).ok()?;
19+
find_first_h1(&ast)
20+
}
21+
22+
/// Walk the AST to find the first depth-1 heading and collect its text content.
23+
fn find_first_h1(node: &Node) -> Option<String> {
24+
match node {
25+
Node::Heading(h) if h.depth == 1 => {
26+
let text = collect_text_content(&h.children);
27+
if text.is_empty() { None } else { Some(text) }
28+
}
29+
_ => {
30+
if let Some(children) = children(node) {
31+
for child in children {
32+
if let Some(heading) = find_first_h1(child) {
33+
return Some(heading);
34+
}
35+
}
36+
}
37+
None
38+
}
39+
}
40+
}
41+
42+
/// Recursively collect all text content from AST nodes.
43+
fn collect_text_content(nodes: &[Node]) -> String {
44+
let mut text = String::new();
45+
for node in nodes {
46+
match node {
47+
Node::Text(t) => text.push_str(&t.value),
48+
Node::InlineCode(c) => text.push_str(&c.value),
49+
other => {
50+
if let Some(children) = children(other) {
51+
text.push_str(&collect_text_content(children));
52+
}
53+
}
54+
}
55+
}
56+
text
57+
}
58+
1359
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1460
pub enum BlockKind {
1561
Paragraph,
@@ -555,4 +601,34 @@ mod tests {
555601
let normalized = normalize_markdown(input).unwrap();
556602
assert!(normalized.blocks.len() >= 2);
557603
}
604+
605+
#[test]
606+
fn extract_first_heading_h1() {
607+
let input = "# Bun Package Manager\n\nsynonyms:: npm, yarn\n";
608+
assert_eq!(
609+
extract_first_heading(input),
610+
Some("Bun Package Manager".to_string())
611+
);
612+
}
613+
614+
#[test]
615+
fn extract_first_heading_skips_h2() {
616+
let input = "## Not This\n\n# This One\n";
617+
assert_eq!(extract_first_heading(input), Some("This One".to_string()));
618+
}
619+
620+
#[test]
621+
fn extract_first_heading_none_when_absent() {
622+
let input = "Just some text\n\n## Only H2\n";
623+
assert_eq!(extract_first_heading(input), None);
624+
}
625+
626+
#[test]
627+
fn extract_first_heading_with_inline_code() {
628+
let input = "# The `bun` Runtime\n";
629+
assert_eq!(
630+
extract_first_heading(input),
631+
Some("The bun Runtime".to_string())
632+
);
633+
}
558634
}

crates/terraphim_agent/src/shared_learning/store.rs

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,7 @@ use thiserror::Error;
1212
use tokio::sync::RwLock;
1313
use tracing::{debug, info};
1414

15-
use crate::shared_learning::types::{
16-
LearningSource, SharedLearning, TrustLevel,
17-
};
15+
use crate::shared_learning::types::{LearningSource, SharedLearning, TrustLevel};
1816

1917
#[derive(Error, Debug)]
2018
pub enum StoreError {
@@ -262,7 +260,10 @@ impl SharedLearningStore {
262260

263261
if let Some((existing_id, score)) = best_match {
264262
if score >= self.config.similarity_threshold {
265-
debug!("Merging with existing learning {} (score={:.3})", existing_id, score);
263+
debug!(
264+
"Merging with existing learning {} (score={:.3})",
265+
existing_id, score
266+
);
266267
self.merge_learning(&existing_id, &learning).await?;
267268
return Ok(StoreResult::Merged(existing_id));
268269
}
@@ -626,7 +627,10 @@ mod tests {
626627

627628
store.insert(learning).await.unwrap();
628629

629-
let suggestions = store.suggest("git push problems", "test-agent", 5).await.unwrap();
630+
let suggestions = store
631+
.suggest("git push problems", "test-agent", 5)
632+
.await
633+
.unwrap();
630634
assert!(!suggestions.is_empty());
631635
assert_eq!(suggestions[0].title, "Git Push Error");
632636
}

crates/terraphim_agent/src/shared_learning/wiki_sync.rs

Lines changed: 25 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -130,12 +130,18 @@ impl GiteaWikiClient {
130130
if exists {
131131
// Update existing page
132132
self.update_wiki_page(&page_name, &content).await?;
133-
info!("Updated wiki page for learning {}: {}", learning.id, page_name);
133+
info!(
134+
"Updated wiki page for learning {}: {}",
135+
learning.id, page_name
136+
);
134137
Ok(SyncResult::Updated(page_name))
135138
} else {
136139
// Create new page
137140
self.create_wiki_page(&page_name, &content).await?;
138-
info!("Created wiki page for learning {}: {}", learning.id, page_name);
141+
info!(
142+
"Created wiki page for learning {}: {}",
143+
learning.id, page_name
144+
);
139145
Ok(SyncResult::Created(page_name))
140146
}
141147
}
@@ -155,7 +161,9 @@ impl GiteaWikiClient {
155161
page_name,
156162
])
157163
.output()
158-
.map_err(|e| WikiSyncError::GiteaRobot(format!("Failed to execute gitea-robot: {}", e)))?;
164+
.map_err(|e| {
165+
WikiSyncError::GiteaRobot(format!("Failed to execute gitea-robot: {}", e))
166+
})?;
159167

160168
if output.status.success() {
161169
Ok(true)
@@ -170,11 +178,7 @@ impl GiteaWikiClient {
170178
}
171179

172180
/// Create a new wiki page
173-
async fn create_wiki_page(
174-
&self,
175-
page_name: &str,
176-
content: &str,
177-
) -> Result<(), WikiSyncError> {
181+
async fn create_wiki_page(&self, page_name: &str, content: &str) -> Result<(), WikiSyncError> {
178182
let output = Command::new(&self.config.robot_path)
179183
.env("GITEA_URL", &self.config.gitea_url)
180184
.env("GITEA_TOKEN", &self.config.token)
@@ -192,7 +196,9 @@ impl GiteaWikiClient {
192196
&format!("Add shared learning: {}", page_name),
193197
])
194198
.output()
195-
.map_err(|e| WikiSyncError::GiteaRobot(format!("Failed to execute gitea-robot: {}", e)))?;
199+
.map_err(|e| {
200+
WikiSyncError::GiteaRobot(format!("Failed to execute gitea-robot: {}", e))
201+
})?;
196202

197203
if output.status.success() {
198204
Ok(())
@@ -207,11 +213,7 @@ impl GiteaWikiClient {
207213
}
208214

209215
/// Update an existing wiki page
210-
async fn update_wiki_page(
211-
&self,
212-
page_name: &str,
213-
content: &str,
214-
) -> Result<(), WikiSyncError> {
216+
async fn update_wiki_page(&self, page_name: &str, content: &str) -> Result<(), WikiSyncError> {
215217
let output = Command::new(&self.config.robot_path)
216218
.env("GITEA_URL", &self.config.gitea_url)
217219
.env("GITEA_TOKEN", &self.config.token)
@@ -229,7 +231,9 @@ impl GiteaWikiClient {
229231
&format!("Update shared learning: {}", page_name),
230232
])
231233
.output()
232-
.map_err(|e| WikiSyncError::GiteaRobot(format!("Failed to execute gitea-robot: {}", e)))?;
234+
.map_err(|e| {
235+
WikiSyncError::GiteaRobot(format!("Failed to execute gitea-robot: {}", e))
236+
})?;
233237

234238
if output.status.success() {
235239
Ok(())
@@ -258,7 +262,9 @@ impl GiteaWikiClient {
258262
page_name,
259263
])
260264
.output()
261-
.map_err(|e| WikiSyncError::GiteaRobot(format!("Failed to execute gitea-robot: {}", e)))?;
265+
.map_err(|e| {
266+
WikiSyncError::GiteaRobot(format!("Failed to execute gitea-robot: {}", e))
267+
})?;
262268

263269
if output.status.success() {
264270
info!("Deleted wiki page: {}", page_name);
@@ -299,7 +305,9 @@ impl GiteaWikiClient {
299305
&self.config.repo,
300306
])
301307
.output()
302-
.map_err(|e| WikiSyncError::GiteaRobot(format!("Failed to execute gitea-robot: {}", e)))?;
308+
.map_err(|e| {
309+
WikiSyncError::GiteaRobot(format!("Failed to execute gitea-robot: {}", e))
310+
})?;
303311

304312
if output.status.success() {
305313
let stdout = String::from_utf8_lossy(&output.stdout);

crates/terraphim_automata/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ readme = "README.md"
1414

1515
[dependencies]
1616
terraphim_types = { path = "../terraphim_types", version = "1.0.0" }
17+
terraphim-markdown-parser = { path = "../terraphim-markdown-parser", version = "1.0.0" }
1718

1819
ahash = { version = "0.8.6", features = ["serde"] }
1920
aho-corasick = "1.0.2"

crates/terraphim_automata/src/builder.rs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -196,11 +196,17 @@ fn concept_from_path(path: PathBuf) -> Result<ConceptWithDisplay> {
196196
let stem = path.file_stem().ok_or(BuilderError::Indexation(format!(
197197
"No file stem in path {path:?}"
198198
)))?;
199-
let original_name = stem.to_string_lossy().to_string();
200-
let concept = Concept::from(original_name.clone());
199+
let stem_name = stem.to_string_lossy().to_string();
200+
201+
// Use heading from markdown directives (parsed when the file is first read).
202+
// Falls back to file stem if directives are unavailable for this path.
203+
let display_name = crate::markdown_directives::extract_heading_from_path(&path)
204+
.unwrap_or_else(|| stem_name.clone());
205+
206+
let concept = Concept::from(stem_name);
201207
Ok(ConceptWithDisplay {
202208
concept,
203-
display_name: original_name,
209+
display_name,
204210
})
205211
}
206212

0 commit comments

Comments
 (0)