use serde_json::json;
use super::planner::{plan_one, tool_for, AgenticPlan, Capability, Progress};
use crate::engine::normalize_prompt;
use crate::protocol::ChatMessage;
use crate::seed;
fn formal_ai_repo() -> String {
config("repository")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct ReportRequest {
pub(super) title: String,
pub(super) body: String,
}
impl ReportRequest {
pub(super) fn gh_command(&self) -> String {
format!(
"gh issue create --repo {} --title {} --body {}",
formal_ai_repo(),
shell_quote(&self.title),
shell_quote(&self.body),
)
}
}
pub(super) fn report_issue_request_for(
task: &str,
messages: &[ChatMessage],
) -> Option<ReportRequest> {
if !is_report_intent(task) {
return None;
}
Some(compose_report(messages))
}
pub(super) fn is_report_intent(task: &str) -> bool {
let normalized = normalize_prompt(task);
let lexicon = seed::lexicon();
let action = lexicon.mentions_role(seed::ROLE_AGENT_ACTION_REPORT_VERB, &normalized);
let bare_action = lexicon
.words_for_role(seed::ROLE_AGENT_ACTION_REPORT_VERB)
.iter()
.any(|word| normalize_prompt(word) == normalized);
bare_action
|| (action
&& lexicon.mentions_role(seed::ROLE_AGENT_ACTION_REPORT_SUBJECT, &normalized)
&& report_action_governs_subject(lexicon, &normalized))
}
fn report_action_governs_subject(lexicon: &seed::Lexicon, normalized: &str) -> bool {
let padded = format!(" {normalized} ");
let matches_for = |role| {
lexicon
.words_for_role(role)
.iter()
.filter_map(|word| {
let word = normalize_prompt(word);
padded
.find(&format!(" {word} "))
.or_else(|| {
(!normalized.contains(char::is_whitespace))
.then(|| normalized.find(&word))
.flatten()
})
.map(|position| (position, word))
})
.collect::<Vec<_>>()
};
let actions = matches_for(seed::ROLE_AGENT_ACTION_REPORT_VERB);
let subjects = matches_for(seed::ROLE_AGENT_ACTION_REPORT_SUBJECT);
let ambiguous_actions = [
seed::ROLE_FILE_WRITE_ACTION_CUE,
seed::ROLE_FILE_WRITE_TARGET_CUE,
]
.into_iter()
.flat_map(|role| lexicon.words_for_role(role))
.map(|word| normalize_prompt(&word))
.collect::<Vec<_>>();
actions.iter().any(|(action_position, action)| {
subjects.iter().any(|(subject_position, _)| {
let distance = action_position.abs_diff(*subject_position);
let natural_order = action_position < subject_position;
if ambiguous_actions
.iter()
.any(|candidate| candidate == action)
{
natural_order && distance <= 16
} else {
distance <= 32
}
})
})
}
fn compose_report(messages: &[ChatMessage]) -> ReportRequest {
let turns: Vec<(String, String)> = messages
.iter()
.filter(|m| m.role.eq_ignore_ascii_case("user") || m.role.eq_ignore_ascii_case("assistant"))
.map(|m| (m.role.to_lowercase(), m.content.plain_text()))
.filter(|(_, text)| !text.trim().is_empty())
.collect();
let subject = turns
.iter()
.rev()
.skip(1)
.find(|(role, _)| role == "user")
.map(|(_, text)| text.trim().to_owned());
let title = match subject.as_deref() {
Some(text) if !text.is_empty() => {
format!(
"{}{}",
config("issue_report_title_prefix"),
truncate(text, 72)
)
}
_ => config("issue_report_default_title"),
};
let mut body = format!("{}\n\n", config("issue_report_body_intro"));
if turns.is_empty() {
body.push('_');
body.push_str(&config("issue_report_empty_history"));
body.push_str("_\n");
} else {
body.push_str("### ");
body.push_str(&config("issue_report_conversation_heading"));
body.push_str("\n\n");
for (role, text) in &turns {
body.push_str("- **");
body.push_str(role);
body.push_str(":** ");
body.push_str(text.trim());
body.push('\n');
}
}
body.push('\n');
body.push_str(&config("issue_report_body_footer"));
ReportRequest { title, body }
}
pub(super) fn plan_report_issue_step(
messages: &[ChatMessage],
tool_names: &[&str],
request: &ReportRequest,
) -> AgenticPlan {
let progress = Progress::scan(messages);
let command = request.gh_command();
if progress.done(Capability::Run) {
return AgenticPlan::Final(final_answer(
&command,
progress.run_output.as_deref().unwrap_or_default(),
));
}
if let Some(tool) = tool_for(tool_names, Capability::Run) {
return plan_one(tool, json!({ "command": command }).to_string());
}
AgenticPlan::Final(render(
"issue_report_tool_missing",
&[("repository", &formal_ai_repo())],
))
}
pub(super) fn final_answer(command: &str, run_output: &str) -> String {
let trimmed = run_output.trim();
trimmed
.split_whitespace()
.find(|token| token.starts_with("https://") && token.contains("/issues/"))
.map_or_else(
|| {
if trimmed.is_empty() {
render("issue_report_ran_command", &[("command", command)])
} else {
format!(
"{}\n\n```text\n{trimmed}\n```",
config("issue_report_created")
)
}
},
|url| render("issue_report_created_with_url", &[("url", url)]),
)
}
fn config(key: &str) -> String {
seed::agent_info()
.remove(key)
.unwrap_or_else(|| key.to_owned())
}
fn render(key: &str, values: &[(&str, &str)]) -> String {
values.iter().fold(config(key), |text, (name, value)| {
text.replace(&format!("{{{name}}}"), value)
})
}
fn shell_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\\''"))
}
fn truncate(value: &str, max: usize) -> String {
let value = value.trim();
if value.chars().count() <= max {
return value.to_owned();
}
let head: String = value.chars().take(max.saturating_sub(1)).collect();
format!("{}…", head.trim_end())
}