formal-ai 0.296.3

Formal symbolic AI implementation with OpenAI-compatible APIs
//! Workspace-backed artifact generation and contextual changes (#715).
//!
//! The latest user turn does not have to repeat the filename or original text:
//! the prior tool-call history is the authoritative mutable-artifact context.

use std::fmt::Write as _;

use lino_objects_codec::format::escape_reference;
use serde_json::{json, Value};

use super::planner::{
    plan_one, tool_capability, tool_for, write_arguments, AgenticPlan, Capability,
};
use crate::coding::{program_language_by_alias, program_task_by_alias, program_template};
use crate::links_substitution_query::{
    parse_substitution_query, render_substitution_query, substitution_effect,
};
use crate::normal_markov::{
    quoted_segments, unwrap_transport_quotes, RewriteHalt, RewriteOutcome, RewriteProgram,
    RewriteRule,
};
use crate::protocol::ChatMessage;

const MAX_REWRITE_STEPS: usize = 100_000;
const RENDERED_TRACE_EDGE_STEPS: usize = 32;

#[derive(Debug, Clone)]
struct WorkspaceArtifact {
    path: String,
    content: String,
}

#[derive(Debug, Clone)]
struct WorkspaceRewrite {
    target: String,
    program: RewriteProgram,
}

impl WorkspaceRewrite {
    fn links_notation(&self, outcome: Option<&RewriteOutcome>) -> String {
        let mut links = String::from("normal_markov_program\n");
        link_field(&mut links, 2, "target", &self.target);
        // The link-cli-dialect query is the meta-language representation the
        // request was lowered to. Everything below it is that query expanded.
        link_field(
            &mut links,
            2,
            "substitution_query",
            &render_substitution_query(&self.program),
        );
        link_field(
            &mut links,
            2,
            "max_steps",
            &self.program.max_steps.to_string(),
        );
        for (index, rule) in self.program.rules.iter().enumerate() {
            let _ = writeln!(links, "  rewrite_rule \"{index}\"");
            link_field(&mut links, 4, "effect", substitution_effect(rule).as_str());
            link_field(&mut links, 4, "pattern", &rule.pattern);
            link_field(&mut links, 4, "replacement", &rule.replacement);
            link_field(&mut links, 4, "terminal", &rule.terminal.to_string());
        }
        if let Some(outcome) = outcome {
            let _ = writeln!(links, "  execution");
            link_field(&mut links, 4, "halt", &format!("{:?}", outcome.halt));
            link_field(&mut links, 4, "steps", &outcome.trace.len().to_string());
            let omitted = outcome
                .trace
                .len()
                .saturating_sub(RENDERED_TRACE_EDGE_STEPS * 2);
            for (index, step) in outcome.trace.iter().enumerate() {
                if omitted > 0 && index == RENDERED_TRACE_EDGE_STEPS {
                    link_field(&mut links, 4, "omitted_steps", &omitted.to_string());
                }
                if omitted > 0
                    && (RENDERED_TRACE_EDGE_STEPS..outcome.trace.len() - RENDERED_TRACE_EDGE_STEPS)
                        .contains(&index)
                {
                    continue;
                }
                let _ = writeln!(
                    links,
                    "    applied rule={} byte_offset={}",
                    step.rule_index, step.byte_offset
                );
            }
        }
        links
    }
}

pub(super) fn plan_code_artifact_step(
    task: &str,
    messages: &[ChatMessage],
    tool_names: &[&str],
) -> Option<AgenticPlan> {
    // OpenCode's positional `run` interface currently preserves one pair of
    // transport quotes in the user content. They are framing, not request data.
    let task = unwrap_transport_quotes(task);
    let write_tool = tool_for(tool_names, Capability::Write)?;
    let latest_user = messages
        .iter()
        .rposition(|message| message.role.eq_ignore_ascii_case("user"))?;

    if let Some(artifact) = latest_workspace_artifact(&messages[..latest_user]) {
        let rewrite = requested_rewrite(task, &artifact)?;
        let current_messages = &messages[latest_user + 1..];
        if let Some(result) = latest_result(current_messages, Capability::Write) {
            let outcome = latest_result(current_messages, Capability::Read)
                .map(|read| rewrite.program.execute(&source_from_read_result(&read)));
            return Some(AgenticPlan::Final(format!(
                "Updated `{}` through the workspace file tools.\n\n{}\n{}",
                artifact.path,
                rewrite.links_notation(outcome.as_ref()),
                result.trim()
            )));
        }
        if let Some(read_source) = latest_result(current_messages, Capability::Read) {
            let current_source = source_from_read_result(&read_source);
            let outcome = rewrite.program.execute(&current_source);
            if outcome.halt == RewriteHalt::StepLimit {
                return Some(AgenticPlan::Final(format!(
                    "Rewrite of `{}` reached its {MAX_REWRITE_STEPS}-step safety bound; no partial bytes were written.\n\n{}",
                    artifact.path,
                    rewrite.links_notation(Some(&outcome))
                )));
            }
            if outcome.output == current_source {
                return Some(AgenticPlan::Final(format!(
                    "No matching source fragment was found in `{}`.",
                    artifact.path
                )));
            }
            return Some(plan_one(
                write_tool,
                write_arguments(&artifact.path, &outcome.output),
            ));
        }
        let read_tool = tool_for(tool_names, Capability::Read)?;
        return Some(plan_one(read_tool, read_arguments(&artifact.path)));
    }

    // With a command capability, let the typed `ExecutionRecipe` path own code
    // creation and verification. Write-only harnesses still receive the source.
    if tool_for(tool_names, Capability::Run).is_some() {
        return None;
    }
    let artifact = generated_artifact(task)?;
    if latest_result(&messages[latest_user + 1..], Capability::Write).is_some() {
        return Some(AgenticPlan::Final(format!(
            "Created `{}` through the workspace file tools.",
            artifact.path
        )));
    }
    Some(plan_one(
        write_tool,
        write_arguments(&artifact.path, &artifact.content),
    ))
}

fn generated_artifact(task: &str) -> Option<WorkspaceArtifact> {
    let normalized = task.to_lowercase();
    let language = program_language_by_alias(&normalized)?;
    let program_task = program_task_by_alias(&normalized)?;
    let template = program_template(program_task.slug, language.slug)?;
    Some(WorkspaceArtifact {
        path: language.save_as.to_owned(),
        content: format!("{}\n", template.code.trim_end()),
    })
}

fn requested_rewrite(task: &str, artifact: &WorkspaceArtifact) -> Option<WorkspaceRewrite> {
    if let Some(program) = explicit_substitution_query(task) {
        return Some(WorkspaceRewrite {
            target: artifact.path.clone(),
            program,
        });
    }
    if task.contains('?') || task.contains('?') {
        return None;
    }
    let quoted = quoted_segments(task);
    let pairs = match quoted.as_slice() {
        [new] => vec![(last_string_literal(&artifact.content)?, new.clone())],
        values if !values.is_empty() && values.len() % 2 == 0 => values
            .chunks_exact(2)
            .map(|pair| (pair[0].clone(), pair[1].clone()))
            .collect(),
        _ => return None,
    };
    if pairs.iter().any(|(old, new)| old == new) {
        return None;
    }
    let single_substitution = pairs.len() == 1;
    let rules = pairs
        .into_iter()
        .map(|(old, new)| {
            let rule = RewriteRule::new(old.clone(), new);
            if single_substitution || old.is_empty() {
                rule.terminal()
            } else {
                rule
            }
        })
        .collect();
    Some(WorkspaceRewrite {
        target: artifact.path.clone(),
        program: RewriteProgram::new(rules, MAX_REWRITE_STEPS),
    })
}

/// Accept the meta-language query itself as the request.
///
/// Prose with quoted operands only ever composes old/new pairs, so this is the
/// only route to link-cli's `()` creation and deletion shorthands and to
/// terminal rules. The whole turn must be the query: requiring that keeps the
/// route unambiguous against ordinary prose that merely contains parentheses.
fn explicit_substitution_query(task: &str) -> Option<RewriteProgram> {
    let trimmed = task.trim();
    if !trimmed.starts_with('(') {
        return None;
    }
    let program = parse_substitution_query(trimmed, MAX_REWRITE_STEPS).ok()?;
    (!program.rules.is_empty()).then_some(program)
}

fn latest_workspace_artifact(messages: &[ChatMessage]) -> Option<WorkspaceArtifact> {
    messages
        .iter()
        .rev()
        .flat_map(|message| message.tool_calls.iter().rev())
        .find_map(|call| {
            if tool_capability(&call.function.name) != Some(Capability::Write) {
                return None;
            }
            let arguments: Value = serde_json::from_str(&call.function.arguments).ok()?;
            let path = argument_string(&arguments, &["path", "filePath", "file_path"])?;
            let content = argument_string(&arguments, &["content"])?;
            Some(WorkspaceArtifact {
                path: path.to_owned(),
                content: content.to_owned(),
            })
        })
}

fn latest_result(messages: &[ChatMessage], capability: Capability) -> Option<String> {
    messages
        .iter()
        .enumerate()
        .rev()
        .find_map(|(index, message)| {
            if !message.role.eq_ignore_ascii_case("tool") {
                return None;
            }
            let call_id = message.tool_call_id.as_ref()?;
            let call = messages[..index]
                .iter()
                .flat_map(|prior| prior.tool_calls.iter())
                .find(|call| &call.id == call_id)?;
            (tool_capability(&call.function.name) == Some(capability))
                .then(|| message.content.plain_text())
        })
}

/// Decode the decorated result emitted by `OpenCode`'s `read` tool. Other CLIs
/// return raw contents, which pass through byte-for-byte.
fn source_from_read_result(result: &str) -> String {
    let Some((_, after_open)) = result.split_once("<content>\n") else {
        return result.to_owned();
    };
    let Some((content, _)) = after_open.rsplit_once("\n</content>") else {
        return result.to_owned();
    };
    let mut decoded = String::new();
    for line in content.lines() {
        if line.starts_with("(End of file - total ") {
            break;
        }
        let source_line = line
            .split_once(": ")
            .filter(|(number, _)| number.chars().all(|character| character.is_ascii_digit()))
            .map_or(line, |(_, source)| source);
        decoded.push_str(source_line);
        decoded.push('\n');
    }
    while decoded.ends_with("\n\n") {
        decoded.pop();
    }
    decoded
}

fn read_arguments(path: &str) -> String {
    json!({"path": path, "filePath": path, "file_path": path}).to_string()
}

fn argument_string<'a>(value: &'a Value, keys: &[&str]) -> Option<&'a str> {
    keys.iter().find_map(|key| value.get(key)?.as_str())
}

fn last_string_literal(source: &str) -> Option<String> {
    quoted_segments(source).pop()
}

/// Write one `name value` link, quoted the way Links Notation quotes.
///
/// The trace is published as the meta-language representation of the request,
/// so it has to be readable by a Links Notation reader, not merely look like
/// one. Notation escapes a quote by *doubling* it and picks a delimiter the
/// value does not already contain; a backslash escape leaves the quote visible
/// to the reader, which ends the string early and turns a code fragment's `(`
/// into an unclosed group. Delegating to the codec's own escaper is what keeps
/// the two definitions from drifting apart again.
fn link_field(out: &mut String, indent: usize, name: &str, value: &str) {
    let _ = writeln!(
        out,
        "{}{name} {}",
        " ".repeat(indent),
        escape_reference(value)
    );
}