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))
}
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())
}