use anyhow::{Context, Result, anyhow};
use std::path::Path;
use std::sync::Arc;
use crate::gateway::kumiho_client::KumihoClient;
use crate::providers::traits::{ChatMessage, ChatRequest, Provider};
use crate::skills::SkillManifest;
use crate::skills::effectiveness_cache::SkillImprovementCandidate;
use crate::skills::improver::SkillImprover;
use crate::skills::registration::publish_skill_revision;
pub struct AutoImproveContext {
pub workspace_dir: std::path::PathBuf,
pub provider: Arc<dyn Provider>,
pub model: String,
pub temperature: f64,
pub kumiho_client: Arc<KumihoClient>,
pub memory_project: String,
}
pub const DEFAULT_REWRITE_TEMPERATURE: f64 = 0.3;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SkillImprovementOutcome {
pub slug: String,
pub revision_kref: String,
pub content_file: String,
}
pub async fn attempt_skill_improvement(
ctx: &AutoImproveContext,
candidate: &SkillImprovementCandidate,
improver: &mut SkillImprover,
) -> Result<Option<SkillImprovementOutcome>> {
if !improver.should_improve_skill(&candidate.skill_name) {
return Ok(None);
}
let skill_dir = ctx.workspace_dir.join("skills").join(&candidate.skill_name);
let manifest_path = skill_dir.join("SKILL.toml");
let manifest_text = match tokio::fs::read_to_string(&manifest_path).await {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
tracing::debug!(
skill = %candidate.skill_name,
path = %manifest_path.display(),
"auto-improve: SKILL.toml not found on disk; skipping",
);
return Ok(None);
}
Err(e) => {
return Err(anyhow!(e).context(format!(
"auto-improve: failed to read {}",
manifest_path.display()
)));
}
};
let manifest: SkillManifest = toml::from_str(&manifest_text)
.with_context(|| format!("auto-improve: parsing {}", manifest_path.display()))?;
let Some(content_rel) = manifest.skill.content_file.clone() else {
tracing::debug!(
skill = %candidate.skill_name,
"auto-improve: skill has no content_file; daemon-startup will migrate it on next run",
);
return Ok(None);
};
let content_path = skill_dir.join(&content_rel);
let current_content = match tokio::fs::read_to_string(&content_path).await {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
tracing::debug!(
skill = %candidate.skill_name,
path = %content_path.display(),
"auto-improve: content_file not found on disk; skipping",
);
return Ok(None);
}
Err(e) => {
return Err(anyhow!(e).context(format!(
"auto-improve: failed to read {}",
content_path.display()
)));
}
};
let prompt = build_improvement_prompt(¤t_content, candidate);
let messages = [
ChatMessage::system(
"You revise the markdown prompts that guide an agent through a skill so they avoid the failure patterns the user reports. \
Return ONLY the complete revised markdown body inside a ```markdown ... ``` fenced code block. \
Preserve the original structure and headings; tighten or expand sections as needed. \
Do not include explanation outside the fence.",
),
ChatMessage::user(prompt),
];
let response = ctx
.provider
.chat(
ChatRequest {
messages: &messages,
tools: None,
},
&ctx.model,
ctx.temperature,
)
.await
.context("auto-improve: LLM chat call failed")?;
let new_content = match extract_markdown_fence(response.text_or_empty()) {
Some(s) => s,
None => {
tracing::warn!(
skill = %candidate.skill_name,
rate = candidate.rate,
"auto-improve: LLM response missing ```markdown fence; skipping",
);
return Ok(None);
}
};
let reason = format!(
"auto-improve: rolling success {:.0}% over last {} outcomes",
candidate.rate * 100.0,
candidate.total
);
let new_file = match improver
.improve_skill(&candidate.skill_name, &new_content, &reason)
.await?
{
Some(p) => p,
None => return Ok(None),
};
let published = publish_skill_revision(
&skill_dir,
&new_file,
&reason,
&ctx.kumiho_client,
&ctx.memory_project,
)
.await
.with_context(|| {
format!(
"auto-improve: publish_skill_revision for {}",
candidate.skill_name
)
})?;
Ok(Some(SkillImprovementOutcome {
slug: candidate.skill_name.clone(),
revision_kref: published.revision_kref,
content_file: published.new_content_file,
}))
}
pub fn build_improvement_prompt(
current_skill_content: &str,
candidate: &SkillImprovementCandidate,
) -> String {
format!(
"The skill below has regressed. Recent rolling success rate is \
**{rate_pct:.0}%** over **{total}** outcomes — well below our \
{threshold_pct:.0}% threshold for healthy skills.\n\n\
Please propose an improved markdown body that addresses common \
failure modes. Focus on:\n\
- clearer step-by-step instructions,\n\
- explicit handling of edge cases the original may have missed,\n\
- safer defaults and tighter guard rails,\n\
- preserving headings and overall structure so the next agent \
can find the same sections.\n\n\
Return ONLY the complete revised markdown content inside a \
```markdown fenced block — no commentary outside the fence.\n\n\
## Current skill content\n\n\
```markdown\n\
{current}\n\
```\n",
rate_pct = candidate.rate * 100.0,
total = candidate.total,
threshold_pct = crate::skills::effectiveness_cache::DEFAULT_IMPROVEMENT_THRESHOLD * 100.0,
current = current_skill_content.trim_end(),
)
}
pub fn extract_markdown_fence(text: &str) -> Option<String> {
extract_fenced_block(text, "markdown")
}
fn extract_fenced_block(text: &str, lang: &str) -> Option<String> {
let opener = format!("```{lang}");
let start = text.find(&opener)?;
let after_open = &text[start + opener.len()..];
let body_start = after_open.find('\n').map(|i| i + 1).unwrap_or(0);
let body = &after_open[body_start..];
let close = body.find("\n```").or_else(|| body.find("```"))?;
let inner = &body[..close];
let trimmed = inner.trim_matches(|c: char| c == '\n' || c == '\r');
let final_trim = trimmed.trim_end();
if final_trim.is_empty() {
None
} else {
Some(final_trim.to_string())
}
}
pub fn skill_toml_path(workspace_dir: &Path, skill_name: &str) -> std::path::PathBuf {
workspace_dir
.join("skills")
.join(skill_name)
.join("SKILL.toml")
}
pub async fn skill_is_writable(workspace_dir: &Path, skill_name: &str) -> bool {
tokio::fs::try_exists(skill_toml_path(workspace_dir, skill_name))
.await
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
fn cand(name: &str, rate: f64, total: u32) -> SkillImprovementCandidate {
SkillImprovementCandidate {
skill_name: name.to_string(),
rate,
total,
}
}
#[test]
fn extract_markdown_fence_basic() {
let text = "Here is the rewrite:\n\
```markdown\n\
# Heading\n\
Body text.\n\
```\n";
let got = extract_markdown_fence(text).expect("fence present");
assert!(got.contains("# Heading"));
assert!(got.contains("Body text."));
}
#[test]
fn extract_markdown_fence_handles_trailing_whitespace_on_opener() {
let text = "```markdown \n\
body\n\
```";
let got = extract_markdown_fence(text).expect("fence present");
assert_eq!(got, "body");
}
#[test]
fn extract_markdown_fence_returns_none_when_missing() {
assert!(extract_markdown_fence("no fence here").is_none());
assert!(extract_markdown_fence("```toml\nx = 1\n```").is_none());
assert!(extract_markdown_fence("```python\nprint('x')\n```").is_none());
}
#[test]
fn extract_markdown_fence_returns_none_when_empty_body() {
let text = "```markdown\n```";
assert!(extract_markdown_fence(text).is_none());
}
#[test]
fn extract_markdown_fence_first_block_only() {
let text = "```markdown\n\
first\n\
```\n\
\n\
```markdown\n\
second\n\
```";
let got = extract_markdown_fence(text).expect("fence present");
assert!(got.contains("first"));
assert!(!got.contains("second"));
}
#[test]
fn extract_markdown_fence_strips_leading_newlines() {
let text = "```markdown\n\n\nbody\n```";
let got = extract_markdown_fence(text).expect("fence present");
assert!(got.starts_with("body"));
}
#[test]
fn build_improvement_prompt_includes_stats() {
let current = "# my-skill\n\nDo the thing.\n";
let prompt = build_improvement_prompt(current, &cand("x", 0.25, 40));
assert!(prompt.contains("**25%**"), "rate not in prompt: {prompt}");
assert!(prompt.contains("**40** outcomes"));
assert!(prompt.contains("40%"));
}
#[test]
fn build_improvement_prompt_includes_current_skill() {
let current = "# sentinel-skill\n\nVersion 0.4.2 instructions.\n";
let prompt = build_improvement_prompt(current, &cand("sentinel-skill", 0.1, 20));
assert!(prompt.contains("sentinel-skill"));
assert!(prompt.contains("0.4.2 instructions"));
assert!(prompt.contains("```markdown"));
}
#[test]
fn build_improvement_prompt_keeps_response_format_strict() {
let prompt = build_improvement_prompt("# x\n", &cand("x", 0.2, 15));
assert!(prompt.contains("Return ONLY"));
assert!(prompt.contains("markdown fenced block"));
}
#[test]
fn skill_toml_path_composes_correctly() {
let p = skill_toml_path(Path::new("/tmp/ws"), "my-skill");
assert!(p.ends_with("skills/my-skill/SKILL.toml"));
}
}