From 44c7d2e61123676bae0e84c037a5b41e0f6dc11f Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Mon, 26 Oct 2026 23:32:15 +0000 Subject: [PATCH 1/3] feat(tool): add MarkkitTool LLM agent tool (Everruns toolkit contract) Opt-in `tool` feature adds markkit::tool: MarkkitToolBuilder -> MarkkitTool -> single-use ToolExecution, mirroring bashkit's tool module. One call converts a url, html or base64 document to Markdown; args validated before execution, base64 bounded before decode, URL via the SSRF guard, output capped by max_output_chars. Adds Markkit::extract_url_document. --- .github/workflows/ci.yml | 1 + AGENTS.md | 1 + CHANGELOG.md | 8 + Cargo.lock | 3 + Cargo.toml | 5 + README.md | 1 + crates/markkit/Cargo.toml | 13 + crates/markkit/src/extract.rs | 17 +- crates/markkit/src/lib.rs | 4 + crates/markkit/src/tool.rs | 1119 +++++++++++++++++++++++ examples/agent_tool.rs | 32 + justfile | 2 + knowledge/index.md | 1 + knowledge/integrations/index.md | 3 + knowledge/integrations/tool-contract.md | 115 +++ knowledge/knowledge-contract.md | 1 + knowledge/log.md | 2 + knowledge/operations/examples.md | 7 + knowledge/security/threat-model.md | 1 + 19 files changed, 1334 insertions(+), 2 deletions(-) create mode 100644 crates/markkit/src/tool.rs create mode 100644 examples/agent_tool.rs create mode 100644 knowledge/integrations/index.md create mode 100644 knowledge/integrations/tool-contract.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 647799b..72c1b1f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,6 +88,7 @@ jobs: for ex in basic custom_selectors configuration ir_access; do cargo run -q -p markkit --example "$ex" done + cargo run -q -p markkit --features tool --example agent_tool check: name: Check diff --git a/AGENTS.md b/AGENTS.md index 9204780..881e4b1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,6 +28,7 @@ Fix root cause. Unsure: read more code; if stuck, ask w/ short options. Unrecogn | foundations/ir | Canonical IR between parsers and the markdown renderer | | formats/html-extraction | HTML element mapping, stripping, whitespace, depth limits | | formats/format-support | Formats, bytes-in API, detection order, DOCX + spreadsheet mapping | +| integrations/tool-contract | `MarkkitTool` LLM tool (`tool` feature), Everruns toolkit contract | | security/security-model | One-page threat/invariant summary | | security/threat-model | Threats T1-T6, mitigations, test map, resource limits, invariants S1-S12 | | runtimes/python-package | PyO3 `markkit` module, maturin, PyPI | diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b78190..2a36203 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Added + +- `tool` feature: `markkit::tool::MarkkitTool`, an LLM agent tool following the Everruns + toolkit-library contract (builder, OpenAI tool definition, input/output schemas, `tower::Service`, + single-use `ToolExecution`, `en-US`/`uk-UA` text). One call converts a `url`, `html` or base64 + document to Markdown, with output capped by `max_output_chars`. Example: `examples/agent_tool.rs`. +- `Markkit::extract_url_document`: `extract_url` with format, title, warnings and stats. + ### Security - `extract_url` (CLI, Python and Node `extract_url` too) now refuses non-public destinations by default: diff --git a/Cargo.lock b/Cargo.lock index 01303b3..b3aa161 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1183,9 +1183,11 @@ name = "markkit" version = "0.1.1" dependencies = [ "anyhow", + "base64", "calamine", "cfb", "criterion", + "futures-core", "html5ever", "proptest", "quick-xml", @@ -1196,6 +1198,7 @@ dependencies = [ "tempfile", "thiserror", "tokio", + "tower", "tracing", "zip", ] diff --git a/Cargo.toml b/Cargo.toml index a67f998..ab34c1a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,6 +52,11 @@ cfb = "0.15" # XLS (OLE2) container pre-scan # Logging tracing = "0.1" +# LLM tool contract (markkit `tool` feature) +tower = { version = "0.5", default-features = false, features = ["util"] } +futures-core = "0.3" +base64 = "0.23" + # Python bindings pyo3 = { version = "0.30" } pyo3-async-runtimes = { version = "0.30", features = ["tokio-runtime"] } diff --git a/README.md b/README.md index 0d9e55a..75fb4d2 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,7 @@ let markkit = Markkit::builder() | `spreadsheet` | No | XLSX/XLSM, XLS, ODS conversion (calamine) | | `pdf` | No | Placeholder; PDF currently returns `UnsupportedFormat` | | `all_formats` | No | `html`, `pdf`, `docx`, `spreadsheet` | +| `tool` | No | `markkit::tool::MarkkitTool` LLM agent tool (Everruns toolkit contract) | | `logging` | No | Reserved: pulls in `tracing`, emits no events yet | `http_client` is optional. Embedders that fetch content themselves can build without diff --git a/crates/markkit/Cargo.toml b/crates/markkit/Cargo.toml index f6f5d96..424777b 100644 --- a/crates/markkit/Cargo.toml +++ b/crates/markkit/Cargo.toml @@ -21,6 +21,8 @@ docx = ["dep:zip", "dep:quick-xml"] spreadsheet = ["dep:calamine", "dep:zip", "dep:quick-xml", "dep:cfb"] http_client = ["dep:reqwest", "tokio/net"] logging = ["dep:tracing"] +# LLM tool contract (`markkit::tool`): Everruns toolkit-library shape. +tool = ["dep:tower", "dep:futures-core", "dep:base64"] all_formats = ["html", "pdf", "docx", "spreadsheet"] [dependencies] @@ -52,12 +54,18 @@ reqwest = { workspace = true, optional = true } # Logging (optional) tracing = { workspace = true, optional = true } +# LLM tool (optional) +tower = { workspace = true, optional = true } +futures-core = { workspace = true, optional = true } +base64 = { workspace = true, optional = true } + [dev-dependencies] anyhow.workspace = true tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "io-util", "time"] } tempfile.workspace = true proptest.workspace = true criterion.workspace = true +tower = { workspace = true } zip = { workspace = true } cfb = { workspace = true } @@ -81,6 +89,11 @@ name = "configuration" path = "../../examples/configuration.rs" required-features = ["html"] +[[example]] +name = "agent_tool" +path = "../../examples/agent_tool.rs" +required-features = ["html", "tool"] + [[bench]] name = "extraction" harness = false diff --git a/crates/markkit/src/extract.rs b/crates/markkit/src/extract.rs index 06ce32b..2f8de85 100644 --- a/crates/markkit/src/extract.rs +++ b/crates/markkit/src/extract.rs @@ -246,9 +246,22 @@ impl Markkit { /// by `max_processing_secs` ([`Error::ResourceLimit`]). #[cfg(feature = "http_client")] pub async fn extract_url(&self, url: &str) -> crate::Result { + self.extract_url_document(url).await.map(|e| e.markdown) + } + + /// [`Markkit::extract_url`] with metadata (format, title, warnings, stats). + /// + /// The response `Content-Type` is the format hint; same URL policy and limits. + #[cfg(feature = "http_client")] + pub async fn extract_url_document(&self, url: &str) -> crate::Result { let fetched = crate::fetch::fetch(url, &self.config).await?; - self.extract_bytes(&fetched.bytes, fetched.content_type.as_deref()) - .await + self.extract_document_sync( + &fetched.bytes, + DocumentHint { + content_type: fetched.content_type.as_deref(), + file_name: None, + }, + ) } /// PDF extraction is not implemented yet; report it explicitly rather than diff --git a/crates/markkit/src/lib.rs b/crates/markkit/src/lib.rs index d6453e2..e037f97 100644 --- a/crates/markkit/src/lib.rs +++ b/crates/markkit/src/lib.rs @@ -36,6 +36,8 @@ //! - **Spreadsheet** (`spreadsheet`): Convert XLSX/XLSM, XLS and ODS workbooks to tables //! - **PDF** (`pdf`): Planned; currently returns [`Error::UnsupportedFormat`] //! - **URL fetching** (`http_client`, default): Fetch and extract from URLs via reqwest +//! - **Agent tool** (`tool`): [`tool::MarkkitTool`], an LLM tool following the +//! Everruns toolkit-library contract (schemas, OpenAI definition, `tower::Service`) //! //! Embedders that do their own fetching can drop reqwest entirely: //! `markkit = { version = "0.1", default-features = false, features = ["docx", "spreadsheet"] }`. @@ -56,6 +58,8 @@ pub mod ir; pub mod render; #[cfg(feature = "spreadsheet")] mod spreadsheet; +#[cfg(feature = "tool")] +pub mod tool; #[cfg(feature = "docx")] mod xml; diff --git a/crates/markkit/src/tool.rs b/crates/markkit/src/tool.rs new file mode 100644 index 0000000..fbc52f9 --- /dev/null +++ b/crates/markkit/src/tool.rs @@ -0,0 +1,1119 @@ +//! LLM tool contract and [`MarkkitTool`]. +//! +//! # Public Library Contract +//! +//! Markkit follows the Everruns toolkit-library contract (same shape as +//! bashkit's `tool` module): +//! +//! ```text +//! ToolBuilder (config) -> Tool (metadata) -> ToolExecution (single-use runtime) +//! ``` +//! +//! [`MarkkitToolBuilder`] configures a reusable tool definition. [`MarkkitTool`] +//! exposes locale-aware metadata plus [`Tool::execution`] for validated, +//! single-use runs. [`ToolExecution`] returns a structured [`ToolOutput`]. +//! +//! Decisions (see `knowledge/integrations/tool-contract.md`): +//! - One tool, one call = one document. Source is exactly one of `url`, `html` +//! or `content_base64`; agents mostly pass a URL, hosts inject attachments. +//! - Tool arguments are untrusted LLM output: validated before an execution +//! exists, base64 length bounded before decoding, URL fetching goes through +//! the core SSRF guard and can be switched off per tool. +//! - Output is capped (`max_output_chars`, default 100 000 chars) so one call +//! cannot flood a context window; the model can lower it per call. +//! - Extraction failures are [`ToolError::UserFacing`] (safe for the model); +//! only I/O and serialization failures are [`ToolError::Internal`]. +//! - No incremental chunks today: the stream closes when execution ends. +//! - Lives in core behind the opt-in `tool` feature so plain library users do +//! not pull `tower`. +//! +//! # Example +//! +//! ``` +//! use markkit::tool::{MarkkitTool, Tool}; +//! +//! # #[tokio::main(flavor = "current_thread")] +//! # async fn main() { +//! let builder = MarkkitTool::builder().locale("en-US"); +//! assert!(builder.build_tool_definition()["function"]["parameters"].is_object()); +//! +//! let tool = builder.build(); +//! assert_eq!(tool.name(), "markkit"); +//! let output = tool +//! .execution(serde_json::json!({"html": "

Hi

there

"})) +//! .expect("valid args") +//! .execute() +//! .await +//! .expect("extraction succeeds"); +//! assert_eq!(output.result["markdown"], "# Hi\n\nthere\n"); +//! assert_eq!(output.result["format"], "html"); +//! # } +//! ``` + +use std::future::Future; +use std::pin::Pin; +use std::sync::{Arc, Mutex}; +use std::task::{Context, Poll}; +use std::time::{Duration, Instant}; + +use base64::Engine as _; +use futures_core::Stream; +use serde::{Deserialize, Serialize}; + +use crate::config::MarkkitBuilder; +use crate::error::Error; +use crate::extract::{Extraction, Markkit}; +use crate::format::DocumentHint; + +type ToolExecutionFuture = Pin> + Send>>; +type ToolExecutionRunner = Box< + dyn FnOnce(Option>) -> ToolExecutionFuture + + Send, +>; + +/// Library version from Cargo.toml. +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// Tool identifier; stable, not localized. +pub const TOOL_NAME: &str = "markkit"; + +/// Default cap on returned Markdown, in characters. +pub const DEFAULT_MAX_OUTPUT_CHARS: usize = 100_000; + +/// Standard `tower::Service` type for toolkit integrations. +pub type ToolService = + tower::util::BoxCloneService; + +/// Tool execution error. +/// +/// [`ToolError::UserFacing`] is short, localized and safe to send back to the +/// LLM. [`ToolError::Internal`] is for logs and stays English. +#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)] +pub enum ToolError { + /// Safe to show to the LLM/user. + #[error("{0}")] + UserFacing(String), + /// Internal failure for logs/diagnostics only. + #[error("{0}")] + Internal(String), +} + +impl ToolError { + /// Whether the message is safe to show to the LLM. + pub fn is_user_facing(&self) -> bool { + matches!(self, Self::UserFacing(_)) + } +} + +/// Image payload returned by a tool. Markkit never emits images; kept for +/// contract parity with other toolkit crates. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ToolImage { + pub base64: String, + pub media_type: String, +} + +/// Consumer-facing metadata that never goes to the LLM. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ToolOutputMetadata { + #[serde(with = "duration_millis")] + pub duration: Duration, + /// Kit-specific diagnostics: `stats` and `chars` (untruncated length). + pub extra: serde_json::Value, +} + +/// Structured execution result. +/// +/// [`ToolOutput::result`] is the JSON payload for the LLM tool result. +/// [`ToolOutput::metadata`] is reserved for the host application. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ToolOutput { + pub result: serde_json::Value, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub images: Vec, + pub metadata: ToolOutputMetadata, +} + +/// Incremental tool output chunk (`kind` is consumer-routable). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ToolOutputChunk { + pub data: serde_json::Value, + pub kind: String, +} + +/// Stream returned by [`ToolExecution::output_stream`]. Informational only; +/// the authoritative result comes from [`ToolExecution::execute`]. +pub struct ToolOutputStream { + receiver: tokio::sync::mpsc::UnboundedReceiver, +} + +impl Stream for ToolOutputStream { + type Item = ToolOutputChunk; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.receiver.poll_recv(cx) + } +} + +#[derive(Default)] +struct ToolExecutionStreamState { + sender: Option>, + receiver: Option>, +} + +/// Stateful, single-use tool execution. +/// +/// Build one with [`Tool::execution`]. Call [`ToolExecution::output_stream`] +/// before [`ToolExecution::execute`] if you need live updates. +pub struct ToolExecution { + runner: Option, + stream_state: Arc>, +} + +impl ToolExecution { + /// Wrap a runner for a [`Tool::execution`] implementation. + /// + /// `runner` receives a stream sender only when the caller asked for + /// [`Self::output_stream`] before executing. + pub fn new(runner: F) -> Self + where + F: FnOnce(Option>) -> Fut + + Send + + 'static, + Fut: Future> + Send + 'static, + { + Self { + runner: Some(Box::new(move |sender| Box::pin(runner(sender)))), + stream_state: Arc::new(Mutex::new(ToolExecutionStreamState::default())), + } + } + + /// Stream incremental output. Must be called before [`Self::execute`]. + /// Returns `None` on a second call. + pub fn output_stream(&self) -> Option { + let mut state = match self.stream_state.lock() { + Ok(state) => state, + Err(poisoned) => poisoned.into_inner(), + }; + if state.sender.is_none() { + let (sender, receiver) = tokio::sync::mpsc::unbounded_channel(); + state.sender = Some(sender); + state.receiver = Some(receiver); + } + state + .receiver + .take() + .map(|receiver| ToolOutputStream { receiver }) + } + + /// Run the execution to completion. + pub async fn execute(mut self) -> Result { + let sender = match self.stream_state.lock() { + Ok(mut state) => state.sender.take(), + Err(poisoned) => poisoned.into_inner().sender.take(), + }; + let Some(runner) = self.runner.take() else { + return Err(ToolError::Internal( + "tool execution may only be run once".to_string(), + )); + }; + runner(sender).await + } +} + +mod duration_millis { + use serde::{Deserialize, Deserializer, Serializer}; + use std::time::Duration; + + pub fn serialize(value: &Duration, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_u64(value.as_millis() as u64) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let millis = u64::deserialize(deserializer)?; + Ok(Duration::from_millis(millis)) + } +} + +// ============================================================================ +// Tool Trait - Public Library Contract +// ============================================================================ + +/// Tool contract for LLM integration. +/// +/// This trait is a **public library contract**; breaking changes need a major +/// version bump. See `knowledge/integrations/tool-contract.md`. +pub trait Tool: Send + Sync { + /// Tool identifier (stable, not localized). + fn name(&self) -> &str; + /// Human-readable display name for UI. + fn display_name(&self) -> &str; + /// One-line description for tool listings. + fn short_description(&self) -> &str; + /// Token-efficient description for LLMs. + fn description(&self) -> &str; + /// Full documentation for LLMs (Markdown). + fn help(&self) -> String; + /// Condensed plain text for system prompts; starts with the tool name. + fn system_prompt(&self) -> String; + /// Locale used for user-facing text. + fn locale(&self) -> &str; + /// JSON Schema for the arguments. + fn input_schema(&self) -> serde_json::Value; + /// JSON Schema for [`ToolOutput::result`]. + fn output_schema(&self) -> serde_json::Value; + /// Library/tool version. + fn version(&self) -> &str; + /// Validate `args` and create a single-use execution. + fn execution(&self, args: serde_json::Value) -> Result; +} + +/// Pick the English or Ukrainian variant of a message for `locale`. +pub fn localized<'a>(locale: &str, en: &'a str, uk: &'a str) -> &'a str { + if locale.starts_with("uk") { uk } else { en } +} + +// ============================================================================ +// MarkkitTool +// ============================================================================ + +/// Builder for [`MarkkitTool`]. +#[derive(Debug, Clone)] +pub struct MarkkitToolBuilder { + locale: String, + config: MarkkitBuilder, + allow_url: bool, + max_output_chars: usize, +} + +impl Default for MarkkitToolBuilder { + fn default() -> Self { + Self::new() + } +} + +impl MarkkitToolBuilder { + /// Defaults: `en-US`, default [`MarkkitBuilder`], URL fetching on when the + /// `http_client` feature is built, [`DEFAULT_MAX_OUTPUT_CHARS`]. + pub fn new() -> Self { + Self { + locale: "en-US".to_string(), + config: MarkkitBuilder::default(), + allow_url: cfg!(feature = "http_client"), + max_output_chars: DEFAULT_MAX_OUTPUT_CHARS, + } + } + + /// Locale for descriptions, prompts, help and user-facing errors + /// (`en-US`, `uk-UA`; others fall back to English). + pub fn locale(mut self, locale: &str) -> Self { + self.locale = locale.to_string(); + self + } + + /// Extraction configuration (limits, selectors, SSRF policy). + pub fn config(mut self, config: MarkkitBuilder) -> Self { + self.config = config; + self + } + + /// Let the model pass `url`. Ignored (always off) without `http_client`. + pub fn allow_url(mut self, yes: bool) -> Self { + self.allow_url = yes; + self + } + + /// Hard cap on returned Markdown characters; per-call `max_chars` can only + /// lower it. + pub fn max_output_chars(mut self, chars: usize) -> Self { + self.max_output_chars = chars; + self + } + + /// Build the tool. Non-consuming so one builder can mint many tools. + pub fn build(&self) -> MarkkitTool { + let locale = self.locale.clone(); + let allow_url = self.allow_url && cfg!(feature = "http_client"); + MarkkitTool { + inner: Arc::new(ToolInner { + display_name: localized(&locale, "Markdown extractor", "Витяг Markdown") + .to_string(), + short_desc: short_description(&locale).to_string(), + description: description(&locale, allow_url), + markkit: self.config.clone().build(), + allow_url, + max_output_chars: self.max_output_chars, + locale, + }), + } + } + + /// Build a `tower::Service`. + pub fn build_service(&self) -> ToolService { + let tool = self.build(); + tower::util::BoxCloneService::new(tower::service_fn(move |args| { + let tool = tool.clone(); + async move { + let output = tool.execution(args)?.execute().await?; + Ok(output.result) + } + })) + } + + /// Build an OpenAI-compatible function tool definition. + pub fn build_tool_definition(&self) -> serde_json::Value { + let tool = self.build(); + serde_json::json!({ + "type": "function", + "function": { + "name": tool.name(), + "description": tool.description(), + "parameters": tool.input_schema(), + } + }) + } + + /// Input schema of the tool this builder makes. + pub fn build_input_schema(&self) -> serde_json::Value { + input_schema(self.allow_url && cfg!(feature = "http_client")) + } + + /// Output schema for [`ToolOutput::result`]. + pub fn build_output_schema(&self) -> serde_json::Value { + output_schema() + } +} + +struct ToolInner { + locale: String, + display_name: String, + short_desc: String, + description: String, + markkit: Markkit, + allow_url: bool, + max_output_chars: usize, +} + +/// Document-to-Markdown extraction tool implementing [`Tool`]. +/// +/// Cheap to clone; clones share configuration. +#[derive(Clone)] +pub struct MarkkitTool { + inner: Arc, +} + +impl MarkkitTool { + /// Create a tool builder. + pub fn builder() -> MarkkitToolBuilder { + MarkkitToolBuilder::new() + } +} + +impl Default for MarkkitTool { + fn default() -> Self { + MarkkitToolBuilder::new().build() + } +} + +impl std::fmt::Debug for MarkkitTool { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("MarkkitTool") + .field("locale", &self.inner.locale) + .field("allow_url", &self.inner.allow_url) + .field("max_output_chars", &self.inner.max_output_chars) + .finish_non_exhaustive() + } +} + +impl Tool for MarkkitTool { + fn name(&self) -> &str { + TOOL_NAME + } + + fn display_name(&self) -> &str { + &self.inner.display_name + } + + fn short_description(&self) -> &str { + &self.inner.short_desc + } + + fn description(&self) -> &str { + &self.inner.description + } + + fn help(&self) -> String { + help( + &self.inner.locale, + self.inner.allow_url, + self.inner.max_output_chars, + ) + } + + fn system_prompt(&self) -> String { + system_prompt(&self.inner.locale, self.inner.allow_url) + } + + fn locale(&self) -> &str { + &self.inner.locale + } + + fn input_schema(&self) -> serde_json::Value { + input_schema(self.inner.allow_url) + } + + fn output_schema(&self) -> serde_json::Value { + output_schema() + } + + fn version(&self) -> &str { + VERSION + } + + fn execution(&self, args: serde_json::Value) -> Result { + let req = parse_request(&self.inner, args)?; + let inner = Arc::clone(&self.inner); + // The stream sender is dropped when the runner returns, closing the + // stream; markkit emits no incremental chunks. + Ok(ToolExecution::new(move |_stream| async move { + let start = now(); + let extraction = run(&inner, req).await?; + output_from_extraction(extraction, elapsed(start)) + })) + } +} + +// ---------------------------------------------------------------------------- +// Request handling +// ---------------------------------------------------------------------------- + +enum Source { + Url(String), + Html(String), + Bytes(Vec), +} + +struct Request { + source: Source, + file_name: Option, + content_type: Option, + max_chars: usize, +} + +fn user_err(locale: &str, en: &str, uk: &str) -> ToolError { + ToolError::UserFacing(localized(locale, en, uk).to_string()) +} + +fn opt_string( + obj: &serde_json::Map, + key: &str, + locale: &str, +) -> Result, ToolError> { + match obj.get(key) { + None | Some(serde_json::Value::Null) => Ok(None), + Some(serde_json::Value::String(s)) => Ok(Some(s.clone())), + Some(_) => Err(ToolError::UserFacing(format!( + "`{key}` {}", + localized(locale, "must be a string", "має бути рядком") + ))), + } +} + +fn parse_request(inner: &ToolInner, args: serde_json::Value) -> Result { + let locale = inner.locale.as_str(); + let Some(obj) = args.as_object() else { + return Err(user_err( + locale, + "tool arguments must be a JSON object", + "аргументи інструмента мають бути JSON-об'єктом", + )); + }; + + let url = opt_string(obj, "url", locale)?; + let html = opt_string(obj, "html", locale)?; + let content_base64 = opt_string(obj, "content_base64", locale)?; + let file_name = opt_string(obj, "file_name", locale)?; + let content_type = opt_string(obj, "content_type", locale)?; + + let max_chars = match obj.get("max_chars") { + None | Some(serde_json::Value::Null) => inner.max_output_chars, + Some(value) => { + let n = value.as_u64().ok_or_else(|| { + user_err( + locale, + "`max_chars` must be a non-negative integer", + "поле `max_chars` має бути невід'ємним цілим числом", + ) + })?; + usize::try_from(n) + .unwrap_or(usize::MAX) + .min(inner.max_output_chars) + } + }; + + let given = [url.is_some(), html.is_some(), content_base64.is_some()] + .into_iter() + .filter(|b| *b) + .count(); + if given != 1 { + return Err(if inner.allow_url { + user_err( + locale, + "pass exactly one of `url`, `html` or `content_base64`", + "передайте рівно одне з полів `url`, `html` або `content_base64`", + ) + } else { + user_err( + locale, + "pass exactly one of `html` or `content_base64`", + "передайте рівно одне з полів `html` або `content_base64`", + ) + }); + } + + let source = if let Some(url) = url { + if !inner.allow_url { + return Err(user_err( + locale, + "URL fetching is disabled for this tool; pass `html` or `content_base64`", + "завантаження за URL вимкнено; передайте `html` або `content_base64`", + )); + } + Source::Url(url) + } else if let Some(html) = html { + Source::Html(html) + } else { + let encoded = content_base64.unwrap_or_default(); + // Bound the work before decoding: 4 base64 chars carry 3 bytes. + let max_encoded = inner + .markkit + .config() + .max_input_size + .saturating_add(2) + .saturating_div(3) + .saturating_mul(4); + if encoded.len() > max_encoded { + return Err(user_err( + locale, + "`content_base64` exceeds the input size limit", + "`content_base64` перевищує ліміт розміру вхідних даних", + )); + } + let bytes = base64::engine::general_purpose::STANDARD + .decode(encoded.trim()) + .map_err(|_| { + user_err( + locale, + "`content_base64` is not valid base64", + "`content_base64` не є коректним base64", + ) + })?; + Source::Bytes(bytes) + }; + + Ok(Request { + source, + file_name, + content_type, + max_chars, + }) +} + +async fn run(inner: &ToolInner, req: Request) -> Result { + let hint = DocumentHint { + content_type: req.content_type.as_deref(), + file_name: req.file_name.as_deref(), + }; + let result = match req.source { + #[cfg(feature = "http_client")] + Source::Url(url) => inner.markkit.extract_url_document(&url).await, + #[cfg(not(feature = "http_client"))] + Source::Url(_) => unreachable!("url rejected in parse_request without http_client"), + Source::Html(html) => inner.markkit.extract_document_sync( + html.as_bytes(), + DocumentHint { + content_type: Some("text/html"), + file_name: None, + }, + ), + Source::Bytes(bytes) => inner.markkit.extract_document_sync(&bytes, hint), + }; + let mut extraction = result.map_err(|err| map_error(&inner.locale, err))?; + if extraction.markdown.chars().count() > req.max_chars { + let cut = extraction + .markdown + .char_indices() + .nth(req.max_chars) + .map_or(extraction.markdown.len(), |(i, _)| i); + let full_chars = extraction.markdown.chars().count(); + extraction.markdown.truncate(cut); + extraction.warnings.push(format!( + "truncated: output cut at {} of {full_chars} chars", + req.max_chars + )); + } + Ok(extraction) +} + +fn map_error(locale: &str, err: Error) -> ToolError { + match err { + Error::Io(e) => ToolError::Internal(format!("I/O error: {e}")), + other => ToolError::UserFacing(format!( + "{}: {other}", + localized(locale, "extraction failed", "не вдалося витягти") + )), + } +} + +// `std::time::Instant` panics on wasm32-unknown-unknown; report zero there. +#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] +fn now() -> Option { + Some(Instant::now()) +} + +#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] +fn now() -> Option { + None +} + +fn elapsed(start: Option) -> Duration { + start.map_or(Duration::ZERO, |s| s.elapsed()) +} + +fn output_from_extraction( + extraction: Extraction, + duration: Duration, +) -> Result { + let truncated = extraction + .warnings + .iter() + .any(|w| w.starts_with("truncated:")); + let stats = serde_json::to_value(&extraction.stats) + .map_err(|e| ToolError::Internal(format!("failed to serialize stats: {e}")))?; + let chars = extraction.markdown.chars().count(); + Ok(ToolOutput { + result: serde_json::json!({ + "markdown": extraction.markdown, + "title": extraction.title, + "format": extraction.format, + "warnings": extraction.warnings, + "truncated": truncated, + }), + images: Vec::new(), + metadata: ToolOutputMetadata { + duration, + extra: serde_json::json!({ "stats": stats, "chars": chars }), + }, + }) +} + +// ---------------------------------------------------------------------------- +// Schemas and localized text +// ---------------------------------------------------------------------------- + +fn input_schema(allow_url: bool) -> serde_json::Value { + let mut properties = serde_json::json!({ + "html": { + "type": "string", + "description": "HTML source to convert" + }, + "content_base64": { + "type": "string", + "description": "Document bytes, base64 (DOCX, XLSX, XLS, ODS, HTML)" + }, + "file_name": { + "type": "string", + "description": "File name hint for content_base64 format detection" + }, + "content_type": { + "type": "string", + "description": "MIME type hint for content_base64 format detection" + }, + "max_chars": { + "type": ["integer", "null"], + "minimum": 0, + "description": "Truncate the returned Markdown to this many characters" + } + }); + if allow_url { + properties["url"] = serde_json::json!({ + "type": "string", + "description": "Public http(s) URL to fetch and convert" + }); + } + serde_json::json!({ + "type": "object", + "properties": properties, + "additionalProperties": false + }) +} + +fn output_schema() -> serde_json::Value { + serde_json::json!({ + "type": "object", + "required": ["markdown", "format", "warnings", "truncated"], + "properties": { + "markdown": { "type": "string", "description": "Extracted Markdown" }, + "title": { "type": ["string", "null"], "description": "Document title when known" }, + "format": { "type": "string", "description": "Detected format (html, docx, xlsx, xls, ods)" }, + "warnings": { + "type": "array", + "items": { "type": "string" }, + "description": "Non-fatal issues, each prefixed with a stable code" + }, + "truncated": { "type": "boolean", "description": "Markdown was cut at max_chars" } + } + }) +} + +fn short_description(locale: &str) -> &'static str { + localized( + locale, + "Convert web pages and documents to Markdown", + "Перетворює вебсторінки та документи на Markdown", + ) +} + +fn description(locale: &str, allow_url: bool) -> String { + match (locale.starts_with("uk"), allow_url) { + (false, true) => "Convert a web page (url), HTML or a document (base64 DOCX/XLSX/XLS/ODS) to clean Markdown.", + (false, false) => "Convert HTML or a document (base64 DOCX/XLSX/XLS/ODS) to clean Markdown.", + (true, true) => "Перетворює вебсторінку (url), HTML або документ (base64 DOCX/XLSX/XLS/ODS) на чистий Markdown.", + (true, false) => "Перетворює HTML або документ (base64 DOCX/XLSX/XLS/ODS) на чистий Markdown.", + } + .to_string() +} + +fn system_prompt(locale: &str, allow_url: bool) -> String { + let uk = locale.starts_with("uk"); + let sources = match (uk, allow_url) { + (false, true) => "Pass one of url, html, content_base64.", + (false, false) => "Pass one of html, content_base64.", + (true, true) => "Передайте одне з: url, html, content_base64.", + (true, false) => "Передайте одне з: html, content_base64.", + }; + let lead = localized( + locale, + "converts web pages and documents to Markdown.", + "перетворює вебсторінки та документи на Markdown.", + ); + let tail = localized( + locale, + "Use max_chars to limit output.", + "max_chars обмежує розмір відповіді.", + ); + format!("{TOOL_NAME}: {lead} {sources} {tail}") +} + +fn help(locale: &str, allow_url: bool, max_output_chars: usize) -> String { + let uk = locale.starts_with("uk"); + let mut doc = String::new(); + doc.push_str(&format!("# {TOOL_NAME}\n\n")); + doc.push_str(&description(locale, allow_url)); + doc.push_str("\n\n"); + doc.push_str(if uk { + "## Аргументи\n\n" + } else { + "## Arguments\n\n" + }); + if allow_url { + doc.push_str(if uk { + "- `url`: публічна http(s) адреса; приватні мережі заблоковано\n" + } else { + "- `url`: public http(s) address; private networks are blocked\n" + }); + } + doc.push_str(if uk { + "- `html`: HTML-код\n\ + - `content_base64`: байти документа в base64, з `file_name` або `content_type` як підказкою формату\n\ + - `max_chars`: обрізати Markdown до цієї кількості символів\n\n" + } else { + "- `html`: HTML source\n\ + - `content_base64`: document bytes as base64, with `file_name` or `content_type` as a format hint\n\ + - `max_chars`: truncate the Markdown to this many characters\n\n" + }); + doc.push_str(&if uk { + format!( + "Рівно одне джерело на виклик. Відповідь не довша за {max_output_chars} символів.\n\n" + ) + } else { + format!( + "Exactly one source per call. Output is capped at {max_output_chars} characters.\n\n" + ) + }); + doc.push_str(if uk { + "## Приклад\n\n" + } else { + "## Example\n\n" + }); + doc.push_str("```json\n{\"html\": \"

Title

Body

\"}\n```\n\n"); + doc.push_str(if uk { + "## Результат\n\n" + } else { + "## Result\n\n" + }); + doc.push_str("`markdown`, `title`, `format`, `warnings`, `truncated`.\n"); + doc +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn tool() -> MarkkitTool { + MarkkitTool::default() + } + + async fn run_ok(tool: &MarkkitTool, args: serde_json::Value) -> ToolOutput { + tool.execution(args) + .expect("valid args") + .execute() + .await + .expect("execution succeeds") + } + + fn err_of(tool: &MarkkitTool, args: serde_json::Value) -> ToolError { + match tool.execution(args) { + Ok(_) => panic!("expected validation error"), + Err(e) => e, + } + } + + #[test] + fn metadata() { + let t = tool(); + assert_eq!(t.name(), "markkit"); + assert_eq!(t.version(), VERSION); + assert_eq!(t.locale(), "en-US"); + assert!(t.system_prompt().starts_with("markkit:")); + assert!(t.help().starts_with("# markkit")); + assert!(!t.description().contains('\n')); + } + + #[test] + fn locale_localizes_text_not_names() { + let t = MarkkitTool::builder().locale("uk-UA").build(); + assert_eq!(t.name(), "markkit"); + assert_eq!(t.display_name(), "Витяг Markdown"); + assert!(t.description().starts_with("Перетворює")); + assert!(t.system_prompt().starts_with("markkit:")); + assert_eq!(t.input_schema(), tool().input_schema()); + match err_of(&t, json!([])) { + ToolError::UserFacing(msg) => assert!(msg.contains("JSON-об'єктом")), + other => panic!("unexpected {other:?}"), + } + } + + #[test] + fn unknown_locale_falls_back_to_english() { + let t = MarkkitTool::builder().locale("fr-FR").build(); + assert_eq!(t.display_name(), "Markdown extractor"); + } + + #[test] + fn builder_helpers_match_tool() { + let b = MarkkitTool::builder(); + let t = b.build(); + let def = b.build_tool_definition(); + assert_eq!(def["type"], "function"); + assert_eq!(def["function"]["name"], "markkit"); + assert_eq!(def["function"]["description"], t.description()); + assert_eq!(def["function"]["parameters"], t.input_schema()); + assert_eq!(b.build_input_schema(), t.input_schema()); + assert_eq!(b.build_output_schema(), t.output_schema()); + } + + #[test] + fn url_property_follows_allow_url() { + let off = MarkkitTool::builder().allow_url(false).build(); + assert!(off.input_schema()["properties"].get("url").is_none()); + assert!(!off.description().contains("url")); + let on = MarkkitTool::builder().allow_url(true).build(); + assert_eq!( + on.input_schema()["properties"].get("url").is_some(), + cfg!(feature = "http_client") + ); + } + + #[tokio::test] + async fn html_source() { + let out = run_ok(&tool(), json!({"html": "

Hi

there

"})).await; + assert_eq!(out.result["markdown"], "# Hi\n\nthere\n"); + assert_eq!(out.result["format"], "html"); + assert_eq!(out.result["truncated"], false); + assert!(out.images.is_empty()); + assert_eq!(out.metadata.extra["chars"], 12); + } + + #[tokio::test] + async fn base64_source_with_hint() { + let encoded = base64::engine::general_purpose::STANDARD.encode("

bytes

"); + let out = run_ok( + &tool(), + json!({"content_base64": encoded, "file_name": "page.html"}), + ) + .await; + assert_eq!(out.result["markdown"], "bytes\n"); + } + + #[tokio::test] + async fn output_matches_schema_shape() { + let out = run_ok(&tool(), json!({"html": "

x

"})).await; + let schema = tool().output_schema(); + for key in schema["required"].as_array().unwrap() { + assert!(out.result.get(key.as_str().unwrap()).is_some(), "{key}"); + } + } + + #[tokio::test] + async fn max_chars_truncates_on_char_boundary() { + let out = run_ok( + &tool(), + json!({"html": "

привіт світ

", "max_chars": 3}), + ) + .await; + assert_eq!(out.result["markdown"], "при"); + assert_eq!(out.result["truncated"], true); + assert!( + out.result["warnings"][0] + .as_str() + .unwrap() + .starts_with("truncated:") + ); + } + + #[tokio::test] + async fn per_call_max_chars_cannot_exceed_builder_cap() { + let t = MarkkitTool::builder().max_output_chars(4).build(); + let out = run_ok(&t, json!({"html": "

abcdefgh

", "max_chars": 1000})).await; + assert_eq!(out.result["markdown"], "abcd"); + } + + #[test] + fn rejects_non_object_args() { + assert!(err_of(&tool(), json!("html")).is_user_facing()); + } + + #[test] + fn rejects_missing_and_multiple_sources() { + assert!(err_of(&tool(), json!({})).is_user_facing()); + assert!( + err_of( + &tool(), + json!({"html": "

a

", "content_base64": "eA=="}) + ) + .is_user_facing() + ); + } + + #[test] + fn rejects_wrong_types() { + assert!(err_of(&tool(), json!({"html": 1})).is_user_facing()); + assert!(err_of(&tool(), json!({"html": "x", "max_chars": -1})).is_user_facing()); + assert!(err_of(&tool(), json!({"html": "x", "max_chars": "5"})).is_user_facing()); + } + + #[test] + fn rejects_invalid_base64() { + let e = err_of(&tool(), json!({"content_base64": "not base64!!"})); + assert_eq!( + e, + ToolError::UserFacing("`content_base64` is not valid base64".into()) + ); + } + + #[test] + fn rejects_oversized_base64_before_decoding() { + let t = MarkkitTool::builder() + .config(MarkkitBuilder::default().max_input_size(3)) + .build(); + // 8 chars decode to 6 bytes > 3-byte limit (max 4 encoded chars). + let e = err_of(&t, json!({"content_base64": "aGVsbG8h"})); + assert!(matches!(e, ToolError::UserFacing(m) if m.contains("size limit"))); + } + + #[test] + fn rejects_url_when_disabled() { + let t = MarkkitTool::builder().allow_url(false).build(); + let e = err_of(&t, json!({"url": "https://example.com"})); + assert!(matches!(e, ToolError::UserFacing(m) if m.contains("disabled"))); + } + + #[cfg(feature = "http_client")] + #[tokio::test] + async fn url_goes_through_ssrf_guard() { + let e = tool() + .execution(json!({"url": "http://127.0.0.1/"})) + .expect("valid args") + .execute() + .await + .expect_err("loopback blocked"); + assert!(matches!(e, ToolError::UserFacing(m) if m.contains("URL blocked"))); + } + + #[tokio::test] + async fn extraction_errors_are_user_facing() { + let t = MarkkitTool::builder() + .config(MarkkitBuilder::default().max_input_size(4)) + .build(); + let e = t + .execution(json!({"html": "

too long

"})) + .unwrap() + .execute() + .await + .expect_err("limit"); + assert!(matches!(e, ToolError::UserFacing(m) if m.contains("Resource limit"))); + } + + #[tokio::test] + async fn unsupported_format_is_user_facing() { + let encoded = base64::engine::general_purpose::STANDARD.encode("%PDF-1.7"); + let e = tool() + .execution(json!({"content_base64": encoded, "file_name": "a.pdf"})) + .unwrap() + .execute() + .await + .expect_err("pdf unsupported"); + assert!(e.is_user_facing()); + } + + #[tokio::test] + async fn stream_closes_without_chunks() { + use std::future::poll_fn; + let exec = tool().execution(json!({"html": "

x

"})).unwrap(); + let mut stream = exec.output_stream().expect("first stream"); + assert!(exec.output_stream().is_none()); + exec.execute().await.unwrap(); + let next = poll_fn(|cx| Pin::new(&mut stream).poll_next(cx)).await; + assert!(next.is_none()); + } + + #[tokio::test] + async fn service_executes() { + use tower::{Service, ServiceExt}; + let mut svc = MarkkitTool::builder().build_service(); + let value = svc + .ready() + .await + .unwrap() + .call(json!({"html": "

svc

"})) + .await + .unwrap(); + assert_eq!(value["markdown"], "svc\n"); + let err = svc + .ready() + .await + .unwrap() + .call(json!({})) + .await + .unwrap_err(); + assert!(err.is_user_facing()); + } +} diff --git a/examples/agent_tool.rs b/examples/agent_tool.rs new file mode 100644 index 0000000..e1bbf84 --- /dev/null +++ b/examples/agent_tool.rs @@ -0,0 +1,32 @@ +//! Agent tool: expose markkit to an LLM via the toolkit-library contract. +//! +//! Run: `cargo run -p markkit --features tool --example agent_tool` + +use markkit::tool::{MarkkitTool, Tool}; + +#[tokio::main(flavor = "current_thread")] +async fn main() -> anyhow::Result<()> { + let builder = MarkkitTool::builder().max_output_chars(2_000); + + // OpenAI-compatible function definition to send with the model request. + println!( + "{}", + serde_json::to_string_pretty(&builder.build_tool_definition())? + ); + + // System prompt snippet. + let tool = builder.build(); + println!("\n{}\n", tool.system_prompt()); + + // Execute arguments the model returned in a tool call. + let args = serde_json::json!({ + "html": "

Release notes

  • Agent tool
  • WASM
" + }); + match tool.execution(args)?.execute().await { + Ok(output) => println!("{}", serde_json::to_string_pretty(&output.result)?), + // UserFacing errors are safe to return to the model as the tool result. + Err(e) if e.is_user_facing() => println!("tool error: {e}"), + Err(e) => return Err(e.into()), + } + Ok(()) +} diff --git a/justfile b/justfile index 85d67fd..86c7327 100644 --- a/justfile +++ b/justfile @@ -62,6 +62,8 @@ examples: echo "── example: $ex" cargo run -q -p markkit --example "$ex" done + echo "── example: agent_tool" + cargo run -q -p markkit --features tool --example agent_tool # ─── Run ───────────────────────────────────────────────────── diff --git a/knowledge/index.md b/knowledge/index.md index 6132cc0..cced66d 100644 --- a/knowledge/index.md +++ b/knowledge/index.md @@ -11,6 +11,7 @@ okf_version: "0.2" * [foundations/](foundations/) - Extraction pipeline architecture and the intermediate representation. * [formats/](formats/) - Per-format parsing: HTML extraction, format detection, DOCX and spreadsheet mapping. +* [integrations/](integrations/) - Agent-facing integrations: the LLM tool contract. * [security/](security/) - Security model and detailed threat model for untrusted input. * [runtimes/](runtimes/) - Published language bindings for Python and Node.js. * [operations/](operations/) - Testing, documentation, examples, maintenance, release, and eval. diff --git a/knowledge/integrations/index.md b/knowledge/integrations/index.md new file mode 100644 index 0000000..33bf266 --- /dev/null +++ b/knowledge/integrations/index.md @@ -0,0 +1,3 @@ +# Integrations + +* [Tool Contract](tool-contract.md) - MarkkitTool LLM tool, its schemas, limits, and error semantics under the Everruns toolkit-library contract. diff --git a/knowledge/integrations/tool-contract.md b/knowledge/integrations/tool-contract.md new file mode 100644 index 0000000..25e0562 --- /dev/null +++ b/knowledge/integrations/tool-contract.md @@ -0,0 +1,115 @@ +--- +type: Interface Contract +title: Tool Contract +description: MarkkitTool LLM tool, its schemas, limits, and error semantics under the Everruns toolkit-library contract. +tags: + - markkit + - tools + - llm + - api +--- + +# Tool Contract + +## Status +Implemented (Rust core, `tool` feature). Bindings, WASM and an MCP server are not wired yet. + +## Summary + +Markkit follows the Everruns toolkit-library contract, the same shape bashkit ships +in its `tool` module: + +```text +MarkkitToolBuilder (config) -> MarkkitTool (metadata) -> ToolExecution (single-use runtime) +``` + +Source: `crates/markkit/src/tool.rs`; example `examples/agent_tool.rs`. + +## Feature gating + +The `tool` module is behind the opt-in `tool` feature (adds `tower`, `futures-core`, +`base64`). Unlike bashkit it is **off by default**: plain library users keep a small +dependency tree. `url` support additionally needs `http_client`. + +## Public API + +`Tool` trait, `MarkkitToolBuilder`, `MarkkitTool`, `ToolExecution`, `ToolOutput`, +`ToolOutputChunk`, `ToolOutputStream`, `ToolError`, `ToolService`, `localized`. + +### Builder rules + +- `build()` is non-consuming; `build_service()` returns + `tower::Service`. +- `build_tool_definition()` emits OpenAI function JSON; `build_input_schema()` / + `build_output_schema()` match the built tool. +- Knobs: `locale`, `config(MarkkitBuilder)` (all extraction limits and the SSRF + policy), `allow_url` (default on with `http_client`), `max_output_chars` + (default 100 000). + +### Metadata rules + +- `name()` is `markkit`, never localized. `display_name()` is `Markdown extractor` / + `Витяг Markdown`. +- `description()` is one sentence; `system_prompt()` starts with `markkit:`; + `help()` is Markdown. All three omit `url` when URL fetching is off. + +### Execution rules + +- `execution()` validates arguments before an execution exists; invalid arguments + never reach a parser. +- `ToolExecution` is single-use; `output_stream()` must be called before `execute()`. + Markkit emits no chunks: the stream closes when execution ends. +- `images` is always empty. + +## Input schema + +Exactly one source per call. + +| Field | Type | Notes | +|---|---|---| +| `url` | string | Only present when `allow_url`; goes through `extract_url_document` (SSRF guard, invariant S12) | +| `html` | string | Parsed as HTML | +| `content_base64` | string | Document bytes; encoded length checked against `max_input_size` before decoding | +| `file_name`, `content_type` | string | Format hints for `content_base64` | +| `max_chars` | integer or null | Truncation; clamped to the builder's `max_output_chars` | + +`additionalProperties` is `false`. + +## Output schema + +`ToolOutput::result`: + +```json +{ "markdown": "string", "title": "string|null", "format": "html|docx|xlsx|xls|ods", + "warnings": ["string"], "truncated": false } +``` + +Truncation cuts on a character boundary and adds a `truncated:` warning. +`metadata.extra` is `{ "stats": ExtractionStats, "chars": }`. +`metadata.duration` is zero on `wasm32-unknown-unknown` (no `Instant` there). + +## Error rules + +- `UserFacing`: argument validation and every extraction `Error` except I/O, + prefixed `extraction failed:` / `не вдалося витягти:`. Safe to return to the model. +- `Internal`: I/O and serialization failures; English, for logs. + +## Locale + +`en-US` and `uk-UA`; anything else falls back to English. Locale changes display +name, descriptions, help, system prompt and `UserFacing` text, never `name()`, +schemas or `version()`. + +## Verification + +Unit tests in `tool::tests`: metadata and locale, builder helpers vs tool, schema +gating of `url`, each source, truncation (multi-byte and builder cap), argument +validation (non-object, missing or multiple sources, wrong types, bad base64, +oversized base64 before decode, disabled URL), SSRF via the tool, error mapping, +stream closing, and the `tower` service. + +## See also + +- [Markkit Architecture](../foundations/architecture.md), the extraction pipeline the tool wraps +- [Threat Model](../security/threat-model.md), T3 SSRF and the tool arguments surface +- [Examples](../operations/examples.md), `agent_tool.rs` diff --git a/knowledge/knowledge-contract.md b/knowledge/knowledge-contract.md index f7fb888..11d511a 100644 --- a/knowledge/knowledge-contract.md +++ b/knowledge/knowledge-contract.md @@ -43,6 +43,7 @@ The bundle declares `okf_version: "0.2"` in the bundle-root [`index.md`](index.m |---|---| | [`foundations/`](foundations/) | Pipeline architecture, IR | | [`formats/`](formats/) | HTML extraction, format detection, office mappings | +| [`integrations/`](integrations/) | LLM tool contract | | [`security/`](security/) | Security model, threat model | | [`runtimes/`](runtimes/) | Python and Node.js packages | | [`operations/`](operations/) | Testing, docs, examples, maintenance, release, eval | diff --git a/knowledge/log.md b/knowledge/log.md index 1b2ff07..9262787 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -2,6 +2,8 @@ ## 2026-10-26 +* **Agent tool**: new [Tool Contract](integrations/tool-contract.md) concept in a new `integrations/` domain. `MarkkitTool` (opt-in `tool` feature) follows the Everruns toolkit-library contract; tool arguments are a new untrusted surface in the [Threat Model](security/threat-model.md). + * **URL fetch hardening**: T3 SSRF is mitigated. `extract_url` blocks non-public addresses by default (URL, resolver and redirect layers), enforces scheme, allowlist, redirect, body and time limits; new invariant S12 and `url_security` tests. Proxy-resolved names are the documented residual. See [Threat Model](security/threat-model.md) and [Security Model](security/security-model.md). * **Dependency trim**: unused dependencies removed (lockfile shrinks by ~460 lines; the `instant`/`paste` unmaintained advisories and their `deny.toml` ignores are gone with the stub bench deps). quick-xml moves to 0.42, one version shared with calamine; markkit's XML walkers now read `&str` names and values. See [Threat Model](security/threat-model.md) T6d and [Maintenance](operations/maintenance.md). * **Inter-block whitespace**: indented HTML lost blank lines between blocks and leaked a leading space into every following line, because whitespace-only text between blocks stayed in the block list as `Text(" ")`. The parser now normalizes block-context lists; golden fixtures, which had recorded the broken output, are regenerated. See [HTML Extraction](formats/html-extraction.md) and [IR Specification](foundations/ir.md). diff --git a/knowledge/operations/examples.md b/knowledge/operations/examples.md index 6005845..6363268 100644 --- a/knowledge/operations/examples.md +++ b/knowledge/operations/examples.md @@ -17,6 +17,7 @@ examples/ custom_selectors.rs # include/exclude CSS selectors configuration.rs # How builder options change output ir_access.rs # Parse to IR, inspect nodes, render separately + agent_tool.rs # LLM tool definition, system prompt, execution ``` Examples live at the workspace root and are registered as `[[example]]` targets @@ -48,6 +49,12 @@ Run one with `cargo run -p markkit --example `, all with `just examples`. - Walk `Document.nodes`, match on `Node` variants - Render with `MarkdownRenderer` to show parse and render are separate stages +### agent_tool.rs + +- Needs the `tool` feature (`required-features = ["html", "tool"]`) +- Prints the OpenAI tool definition and system prompt, then executes model-style + arguments and prints `ToolOutput::result` (see [Tool Contract](../integrations/tool-contract.md)) + ## Requirements - No network or file access; inline HTML samples only diff --git a/knowledge/security/threat-model.md b/knowledge/security/threat-model.md index 49d78f1..0b6315b 100644 --- a/knowledge/security/threat-model.md +++ b/knowledge/security/threat-model.md @@ -18,6 +18,7 @@ tags: | URL fetching | `extract_url()` (http_client feature) | Untrusted remote | | Byte input | `extract_bytes()` with content-type | Untrusted | | Document input | `extract_document()`, `extract_document_sync()` | Untrusted (bytes and `DocumentHint`) | +| LLM tool arguments | `MarkkitTool::execution()` (tool feature) | Untrusted (model output); validated before execution, see [Tool Contract](../integrations/tool-contract.md) | | Configuration | `MarkkitBuilder` methods | Trusted (caller-controlled) | | CSS selectors | `include_selector()`, `exclude_selector()` | Semi-trusted (caller-provided) | From 03fc1b404b267978556026298f0f4f17a9a21d78 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Mon, 26 Oct 2026 23:33:55 +0000 Subject: [PATCH 2/3] fix(tool): silence dead-code warning without http_client --- crates/markkit/src/tool.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/markkit/src/tool.rs b/crates/markkit/src/tool.rs index fbc52f9..b14ccee 100644 --- a/crates/markkit/src/tool.rs +++ b/crates/markkit/src/tool.rs @@ -494,6 +494,7 @@ impl Tool for MarkkitTool { // ---------------------------------------------------------------------------- enum Source { + #[cfg_attr(not(feature = "http_client"), allow(dead_code))] Url(String), Html(String), Bytes(Vec), From 3582fe05849fa56d4c53c6e10eb1b399dfa7a278 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Mon, 26 Oct 2026 23:36:52 +0000 Subject: [PATCH 3/3] docs(tool): avoid feature-gated intra-doc link --- crates/markkit/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/markkit/src/lib.rs b/crates/markkit/src/lib.rs index e037f97..80319e9 100644 --- a/crates/markkit/src/lib.rs +++ b/crates/markkit/src/lib.rs @@ -36,7 +36,7 @@ //! - **Spreadsheet** (`spreadsheet`): Convert XLSX/XLSM, XLS and ODS workbooks to tables //! - **PDF** (`pdf`): Planned; currently returns [`Error::UnsupportedFormat`] //! - **URL fetching** (`http_client`, default): Fetch and extract from URLs via reqwest -//! - **Agent tool** (`tool`): [`tool::MarkkitTool`], an LLM tool following the +//! - **Agent tool** (`tool`): `markkit::tool::MarkkitTool`, an LLM tool following the //! Everruns toolkit-library contract (schemas, OpenAI definition, `tower::Service`) //! //! Embedders that do their own fetching can drop reqwest entirely: