use serde_json::json;
use super::formalize::{
coverage_line, formalize_text_to_links, FormalizedKnowledgeBase, CANONICAL_FISHERMAN_SYNOPSIS,
FISHERMAN_DOC_ID,
};
use crate::protocol::ChatMessage;
pub const SEARCH_QUERY: &str = "Пушкин Сказка о рыбаке и рыбке полный текст";
pub const CANONICAL_SOURCE_URL: &str =
"https://ru.wikisource.org/wiki/Сказка_о_рыбаке_и_рыбке_(Пушкин)";
pub const KB_PATH: &str = "knowledge-base.lino";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AgenticPlan {
ToolCalls(Vec<PlannedToolCall>),
Final(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PlannedToolCall {
pub tool: String,
pub arguments: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Capability {
Search,
Fetch,
Write,
Run,
}
#[must_use]
pub fn plan_chat_step(messages: &[ChatMessage], tool_names: &[&str]) -> Option<AgenticPlan> {
let task = latest_user_text(messages)?;
if !is_formalization_task(&task) {
return None;
}
let search_tool = tool_for(tool_names, Capability::Search);
let fetch_tool = tool_for(tool_names, Capability::Fetch);
let write_tool = tool_for(tool_names, Capability::Write);
let run_tool = tool_for(tool_names, Capability::Run);
let progress = Progress::scan(messages);
if let Some(tool) = search_tool {
if !progress.done(Capability::Search) {
return Some(plan_one(tool, json!({ "query": SEARCH_QUERY }).to_string()));
}
}
if let Some(tool) = fetch_tool {
if !progress.done(Capability::Fetch) {
return Some(plan_one(
tool,
json!({ "url": CANONICAL_SOURCE_URL }).to_string(),
));
}
}
let source = progress
.fetched_text
.as_deref()
.unwrap_or(CANONICAL_FISHERMAN_SYNOPSIS);
let formalized = formalize_text_to_links(source, "");
if let Some(tool) = write_tool {
if !progress.done(Capability::Write) {
let arguments = json!({ "path": KB_PATH, "content": formalized.links_notation });
return Some(plan_one(tool, arguments.to_string()));
}
}
if let Some(tool) = run_tool {
if !progress.done(Capability::Run) {
let arguments = json!({ "command": format!("cat {KB_PATH}") });
return Some(plan_one(tool, arguments.to_string()));
}
}
Some(AgenticPlan::Final(final_answer(&formalized)))
}
struct Progress {
completed: Vec<Capability>,
fetched_text: Option<String>,
}
impl Progress {
fn scan(messages: &[ChatMessage]) -> Self {
let mut completed = Vec::new();
let mut fetched_text = None;
for (index, message) in messages.iter().enumerate() {
if !message.role.eq_ignore_ascii_case("tool") {
continue;
}
let Some(capability) = result_capability(messages, index) else {
continue;
};
if capability == Capability::Fetch {
let text = message.content.plain_text();
if !looks_like_error(&text) && !text.trim().is_empty() {
fetched_text = Some(text);
}
}
if !completed.contains(&capability) {
completed.push(capability);
}
}
Self {
completed,
fetched_text,
}
}
fn done(&self, capability: Capability) -> bool {
self.completed.contains(&capability)
}
}
fn plan_one(tool: &str, arguments: String) -> AgenticPlan {
AgenticPlan::ToolCalls(vec![PlannedToolCall {
tool: tool.to_owned(),
arguments,
}])
}
fn tool_for<'a>(tool_names: &[&'a str], capability: Capability) -> Option<&'a str> {
tool_names
.iter()
.copied()
.find(|name| classify_tool(name) == Some(capability))
}
fn classify_tool(name: &str) -> Option<Capability> {
let lower = name.to_ascii_lowercase();
if lower.contains("search") {
Some(Capability::Search)
} else if lower.contains("fetch")
|| lower.contains("open")
|| lower.contains("browse")
|| lower.contains("get_url")
|| lower.contains("read_url")
{
Some(Capability::Fetch)
} else if lower.contains("write") {
Some(Capability::Write)
} else if lower.contains("run")
|| lower.contains("bash")
|| lower.contains("command")
|| lower.contains("exec")
|| lower.contains("shell")
{
Some(Capability::Run)
} else {
None
}
}
fn result_capability(messages: &[ChatMessage], index: usize) -> Option<Capability> {
let message = &messages[index];
if let Some(name) = &message.name {
if let Some(capability) = classify_tool(name) {
return Some(capability);
}
}
let call_id = message.tool_call_id.as_ref()?;
messages[..index]
.iter()
.flat_map(|prior| prior.tool_calls.iter())
.find(|call| &call.id == call_id)
.and_then(|call| classify_tool(&call.function.name))
}
fn latest_user_text(messages: &[ChatMessage]) -> Option<String> {
messages
.iter()
.rev()
.find(|message| message.role.eq_ignore_ascii_case("user"))
.map(|message| message.content.plain_text())
}
const FORMALIZATION_KEYWORDS: [&str; 7] = [
"formaliz",
"формализ",
"knowledge base",
"links notation",
"рыбак",
"fisherman",
"сказк",
];
fn is_formalization_task(prompt: &str) -> bool {
let lower = prompt.to_lowercase();
FORMALIZATION_KEYWORDS
.iter()
.any(|keyword| lower.contains(keyword))
}
fn looks_like_error(text: &str) -> bool {
let lower = text.to_lowercase();
["error", "failed", "not found", "404"]
.iter()
.any(|needle| lower.contains(needle))
}
fn final_answer(formalized: &FormalizedKnowledgeBase) -> String {
let summary = &formalized.summary;
let subject = if summary.doc_id == FISHERMAN_DOC_ID {
"«Сказка о рыбаке и рыбке»".to_owned()
} else {
format!("the source text ({})", summary.doc_id)
};
format!(
"Formalized {subject} into a Links Notation knowledge base: {records} records realising \
all nine protocol primitives ({coverage}).\n\nKnowledge base ({KB_PATH}):\n\n{kb}",
records = summary.total_records(),
coverage = coverage_line(summary),
kb = formalized.links_notation.trim_end(),
)
}