use serde_json::json;
use super::diagram;
use super::formalize::{
coverage_line, formalize_text_to_links, FormalizedKnowledgeBase, CANONICAL_FISHERMAN_SYNOPSIS,
FISHERMAN_DOC_ID,
};
use super::meaning_detail;
use super::self_ast;
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)]
pub enum Capability {
Search,
Fetch,
Write,
Run,
}
impl Capability {
#[must_use]
pub const fn permission_key(self) -> &'static str {
match self {
Self::Search => "tool:capability:search",
Self::Fetch => "tool:capability:fetch",
Self::Write => "tool:capability:write",
Self::Run => "tool:capability:run",
}
}
}
#[must_use]
pub fn tool_capability(name: &str) -> Option<Capability> {
classify_tool(name)
}
#[must_use]
pub fn plan_chat_step(messages: &[ChatMessage], tool_names: &[&str]) -> Option<AgenticPlan> {
let task = latest_user_text(messages)?;
if self_ast::is_self_ast_task(&task) {
return Some(plan_self_ast_step(messages, tool_names));
}
if let Some(command) = shell_command_for_task(&task) {
return Some(plan_shell_step(messages, tool_names, &command));
}
if is_formalization_task(&task) {
return Some(plan_formalization_step(messages, tool_names));
}
if meaning_detail::is_meaning_detail_task(&task) {
return Some(plan_meaning_detail_step(&task, messages, tool_names));
}
if diagram::is_diagram_task(&task) {
return Some(plan_diagram_step(messages, tool_names));
}
None
}
fn plan_shell_step(messages: &[ChatMessage], tool_names: &[&str], command: &str) -> AgenticPlan {
let progress = Progress::scan(messages);
if progress.done(Capability::Run) {
return AgenticPlan::Final(shell_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(format!(
"I can run `{command}` when the client advertises a shell tool such as `bash`, `shell`, or `run_command`."
))
}
fn plan_formalization_step(messages: &[ChatMessage], tool_names: &[&str]) -> AgenticPlan {
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 plan_one(tool, json!({ "query": SEARCH_QUERY }).to_string());
}
}
if let Some(tool) = fetch_tool {
if !progress.done(Capability::Fetch) {
return plan_one(tool, fetch_arguments(CANONICAL_SOURCE_URL));
}
}
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) {
return plan_one(tool, write_arguments(KB_PATH, &formalized.links_notation));
}
}
if let Some(tool) = run_tool {
if !progress.done(Capability::Run) {
let arguments = json!({ "command": format!("cat {KB_PATH}") });
return plan_one(tool, arguments.to_string());
}
}
AgenticPlan::Final(final_answer(&formalized))
}
fn plan_meaning_detail_step(
task: &str,
messages: &[ChatMessage],
tool_names: &[&str],
) -> AgenticPlan {
let concept = meaning_detail::concept_for_task(task).unwrap_or(&meaning_detail::TOMATO);
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 plan_one(tool, json!({ "query": concept.search_query }).to_string());
}
}
if let Some(tool) = fetch_tool {
if !progress.done(Capability::Fetch) {
return plan_one(tool, fetch_arguments(concept.source_url));
}
}
let block = meaning_detail::enrich_block(concept, progress.fetched_text.as_deref());
if let Some(tool) = write_tool {
if !progress.done(Capability::Write) {
return plan_one(tool, write_arguments(concept.kb_path, &block));
}
}
if let Some(tool) = run_tool {
if !progress.done(Capability::Run) {
let arguments = json!({ "command": format!("cat {}", concept.kb_path) });
return plan_one(tool, arguments.to_string());
}
}
AgenticPlan::Final(meaning_detail::final_answer_for(concept, &block))
}
fn plan_diagram_step(messages: &[ChatMessage], tool_names: &[&str]) -> AgenticPlan {
let write_tool = tool_for(tool_names, Capability::Write);
let run_tool = tool_for(tool_names, Capability::Run);
let progress = Progress::scan(messages);
let document = diagram::render_document();
if let Some(tool) = write_tool {
if !progress.done(Capability::Write) {
return plan_one(tool, write_arguments(diagram::DIAGRAM_PATH, &document));
}
}
if let Some(tool) = run_tool {
if !progress.done(Capability::Run) {
let arguments = json!({ "command": format!("cat {}", diagram::DIAGRAM_PATH) });
return plan_one(tool, arguments.to_string());
}
}
AgenticPlan::Final(diagram::final_answer(&document))
}
fn plan_self_ast_step(messages: &[ChatMessage], tool_names: &[&str]) -> AgenticPlan {
let write_tool = tool_for(tool_names, Capability::Write);
let run_tool = tool_for(tool_names, Capability::Run);
let progress = Progress::scan(messages);
let document = self_ast::render_document();
if let Some(tool) = write_tool {
if !progress.done(Capability::Write) {
return plan_one(tool, write_arguments(self_ast::AST_PATH, &document));
}
}
if let Some(tool) = run_tool {
if !progress.done(Capability::Run) {
let arguments = json!({ "command": format!("cat {}", self_ast::AST_PATH) });
return plan_one(tool, arguments.to_string());
}
}
AgenticPlan::Final(self_ast::final_answer(&document))
}
struct Progress {
completed: Vec<Capability>,
fetched_text: Option<String>,
run_output: Option<String>,
}
impl Progress {
fn scan(messages: &[ChatMessage]) -> Self {
let mut completed = Vec::new();
let mut fetched_text = None;
let mut run_output = 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 capability == Capability::Run {
run_output = Some(message.content.plain_text());
}
if !completed.contains(&capability) {
completed.push(capability);
}
}
Self {
completed,
fetched_text,
run_output,
}
}
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 write_arguments(path: &str, content: &str) -> String {
json!({
"path": path,
"filePath": path,
"file_path": path,
"content": content,
})
.to_string()
}
fn fetch_arguments(url: &str) -> String {
json!({
"url": url,
"format": "text",
})
.to_string()
}
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("todo") {
return None;
}
if lower.contains("search") {
(!lower.contains("code")).then_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") || lower.contains("create_file") {
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 shell_command_for_task(prompt: &str) -> Option<String> {
let lower = prompt.to_ascii_lowercase();
let mentions_ls = contains_word(&lower, "ls");
let run_context = ["run", "execute", "command", "terminal", "shell"]
.iter()
.any(|word| contains_word(&lower, word));
let listing_context = [
"list files",
"list the files",
"list local files",
"list directory",
"files here",
"current directory",
"working directory",
]
.iter()
.any(|phrase| lower.contains(phrase));
if mentions_ls && (run_context || listing_context) {
return Some(String::from("ls"));
}
let asks_for_listing = [
"list files",
"list the files",
"list local files",
"list directory",
]
.iter()
.any(|phrase| lower.contains(phrase));
let local_scope = [
"here",
"current directory",
"working directory",
"this directory",
"local files",
]
.iter()
.any(|phrase| lower.contains(phrase));
(asks_for_listing && local_scope).then(|| String::from("ls"))
}
fn contains_word(text: &str, word: &str) -> bool {
text.split(|character: char| !character.is_ascii_alphanumeric())
.any(|part| part == word)
}
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(),
)
}
fn shell_final_answer(command: &str, output: &str) -> String {
let trimmed = output.trim_end();
if trimmed.is_empty() {
format!("The `{command}` command completed with no output.")
} else {
format!("The `{command}` command completed. Output:\n\n```text\n{trimmed}\n```")
}
}