mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-09 22:33:37 +00:00
Parse routing directives from LLM response text in CodergenHandler
When CodergenHandler receives CodergenResult::Text, it now extracts preferred_next_label, suggested_next_ids, and context_updates from the last JSON object in the response. This enables edge selection via condition matching and preferred label instead of always falling through to unconditional edges. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
242e82fffd
commit
6fee3d82f1
1 changed files with 174 additions and 0 deletions
|
|
@ -44,6 +44,95 @@ fn expand_variables(text: &str, graph: &Graph) -> String {
|
|||
text.replace("$goal", graph.goal())
|
||||
}
|
||||
|
||||
/// Status fields that indicate a JSON object contains routing directives.
|
||||
const STATUS_FIELDS: &[&str] = &[
|
||||
"preferred_next_label",
|
||||
"outcome",
|
||||
"suggested_next_ids",
|
||||
"context_updates",
|
||||
];
|
||||
|
||||
/// Find all balanced `{...}` JSON object substrings in the text.
|
||||
fn find_json_objects(text: &str) -> Vec<&str> {
|
||||
let mut results = Vec::new();
|
||||
let bytes = text.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'{' {
|
||||
let start = i;
|
||||
let mut depth = 0;
|
||||
let mut in_string = false;
|
||||
let mut escape = false;
|
||||
let mut j = i;
|
||||
while j < bytes.len() {
|
||||
let c = bytes[j];
|
||||
if escape {
|
||||
escape = false;
|
||||
} else if c == b'\\' && in_string {
|
||||
escape = true;
|
||||
} else if c == b'"' {
|
||||
in_string = !in_string;
|
||||
} else if !in_string {
|
||||
if c == b'{' {
|
||||
depth += 1;
|
||||
} else if c == b'}' {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
results.push(&text[start..=j]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
results
|
||||
}
|
||||
|
||||
/// Extract routing directives from LLM response text.
|
||||
///
|
||||
/// Searches for the last JSON object in the response that contains at least
|
||||
/// one status field (`preferred_next_label`, `outcome`, `suggested_next_ids`,
|
||||
/// `context_updates`). Merges extracted fields into the outcome.
|
||||
fn extract_status_fields(text: &str, outcome: &mut Outcome) {
|
||||
let candidates = find_json_objects(text);
|
||||
|
||||
let parsed = candidates.iter().rev().find_map(|candidate| {
|
||||
let value: serde_json::Value = serde_json::from_str(candidate).ok()?;
|
||||
if let Some(obj) = value.as_object() {
|
||||
if STATUS_FIELDS.iter().any(|f| obj.contains_key(*f)) {
|
||||
return Some(value);
|
||||
}
|
||||
}
|
||||
None
|
||||
});
|
||||
|
||||
let Some(value) = parsed else { return };
|
||||
let Some(obj) = value.as_object() else { return };
|
||||
|
||||
if let Some(label) = obj.get("preferred_next_label").and_then(|v| v.as_str()) {
|
||||
outcome.preferred_label = Some(label.to_string());
|
||||
}
|
||||
|
||||
if let Some(ids) = obj.get("suggested_next_ids").and_then(|v| v.as_array()) {
|
||||
let string_ids: Vec<String> = ids
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str().map(String::from))
|
||||
.collect();
|
||||
if !string_ids.is_empty() {
|
||||
outcome.suggested_next_ids = string_ids;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(updates) = obj.get("context_updates").and_then(|v| v.as_object()) {
|
||||
for (key, val) in updates {
|
||||
outcome.context_updates.insert(key.clone(), val.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Truncate a string to at most `max_chars` characters.
|
||||
fn truncate(s: &str, max_chars: usize) -> &str {
|
||||
if s.len() <= max_chars {
|
||||
|
|
@ -154,6 +243,9 @@ impl Handler for CodergenHandler {
|
|||
serde_json::json!(truncate(&response_text, 200)),
|
||||
);
|
||||
|
||||
// 7b. Parse routing directives from response text
|
||||
extract_status_fields(&response_text, &mut outcome);
|
||||
|
||||
let status_json = serde_json::to_string_pretty(&outcome)
|
||||
.unwrap_or_else(|_| "{}".to_string());
|
||||
tokio::fs::write(stage_dir.join("status.json"), &status_json).await?;
|
||||
|
|
@ -508,6 +600,88 @@ mod tests {
|
|||
assert!(err.to_string().contains("Request timed out"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_status_fields_from_fenced_code_block() {
|
||||
let text = r#"Here is my analysis of the code.
|
||||
|
||||
```json
|
||||
{"preferred_next_label": "fix", "outcome": "success"}
|
||||
```
|
||||
|
||||
That's it."#;
|
||||
let mut outcome = Outcome::success();
|
||||
extract_status_fields(text, &mut outcome);
|
||||
assert_eq!(outcome.preferred_label.as_deref(), Some("fix"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_status_fields_from_bare_json() {
|
||||
let text = r#"I recommend routing to fix.
|
||||
{"preferred_next_label": "fix_batch"}"#;
|
||||
let mut outcome = Outcome::success();
|
||||
extract_status_fields(text, &mut outcome);
|
||||
assert_eq!(outcome.preferred_label.as_deref(), Some("fix_batch"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_status_fields_no_json() {
|
||||
let text = "Just some plain text response with no JSON at all.";
|
||||
let mut outcome = Outcome::success();
|
||||
extract_status_fields(text, &mut outcome);
|
||||
assert!(outcome.preferred_label.is_none());
|
||||
assert!(outcome.suggested_next_ids.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_status_fields_json_without_status_fields() {
|
||||
let text = r#"Here is some data: {"name": "test", "count": 42}"#;
|
||||
let mut outcome = Outcome::success();
|
||||
extract_status_fields(text, &mut outcome);
|
||||
assert!(outcome.preferred_label.is_none());
|
||||
assert!(outcome.suggested_next_ids.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_status_fields_context_updates_and_suggested_ids() {
|
||||
let text = r#"```json
|
||||
{
|
||||
"preferred_next_label": "review",
|
||||
"suggested_next_ids": ["node_a", "node_b"],
|
||||
"context_updates": {"fix.files_changed": 3, "fix.summary": "patched"}
|
||||
}
|
||||
```"#;
|
||||
let mut outcome = Outcome::success();
|
||||
outcome
|
||||
.context_updates
|
||||
.insert("existing_key".to_string(), serde_json::json!("keep"));
|
||||
extract_status_fields(text, &mut outcome);
|
||||
assert_eq!(outcome.preferred_label.as_deref(), Some("review"));
|
||||
assert_eq!(outcome.suggested_next_ids, vec!["node_a", "node_b"]);
|
||||
assert_eq!(
|
||||
outcome.context_updates.get("fix.files_changed"),
|
||||
Some(&serde_json::json!(3))
|
||||
);
|
||||
assert_eq!(
|
||||
outcome.context_updates.get("fix.summary"),
|
||||
Some(&serde_json::json!("patched"))
|
||||
);
|
||||
// Existing keys preserved
|
||||
assert_eq!(
|
||||
outcome.context_updates.get("existing_key"),
|
||||
Some(&serde_json::json!("keep"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_status_fields_uses_last_match() {
|
||||
let text = r#"{"preferred_next_label": "first"}
|
||||
Some text in between.
|
||||
{"preferred_next_label": "second"}"#;
|
||||
let mut outcome = Outcome::success();
|
||||
extract_status_fields(text, &mut outcome);
|
||||
assert_eq!(outcome.preferred_label.as_deref(), Some("second"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn codergen_handler_returns_fail_outcome_for_non_retryable_backend_error() {
|
||||
struct ValidationFailBackend;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue