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 = contains_any(
&lower,
&[
"list files",
"list the files",
"list local files",
"list directory",
"files here",
"current directory",
"working directory",
],
);
if mentions_ls && (run_context || listing_context) {
return Some(String::from("ls"));
}
let asks_for_listing = contains_any(
&lower,
&[
"list files",
"list the files",
"list local files",
"list directory",
"directory listing",
"directory contents",
"folder contents",
"contents of this directory",
"contents of the current directory",
"contents of this folder",
"contents of the current folder",
],
);
let asks_which_files = contains_any(
&lower,
&[
"what files",
"which files",
"files are in",
"files exist",
"files are here",
],
);
let local_scope = contains_any(
&lower,
&[
"here",
"current directory",
"working directory",
"current working directory",
"this directory",
"current folder",
"this folder",
"local files",
],
);
((asks_for_listing || asks_which_files) && local_scope).then(|| String::from("ls"))
}
fn contains_any(text: &str, phrases: &[&str]) -> bool {
phrases.iter().any(|phrase| text.contains(phrase))
}
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```")
}
}