use std::path::Path;
use std::sync::{Arc, Mutex};
use kernel::{
AxCode, AxError, CostTier, Effect, Payload, RenderIntent, Temporal, Tool, ToolCall, ToolMeta,
ToolName, ToolOutcome,
};
use serde_json::{Map, Value};
pub(super) mod locator;
mod miss;
mod package;
use super::BoundReader;
use super::chosen_path::{Located, ReadBound};
use crate::catalog::{Catalog, Expansion};
use miss::Floor;
const LINE_CAP: u16 = 512;
enum Found {
File {
at: Located,
floor: Floor,
},
Text(String),
}
struct Interval {
offset: u64,
limit: u16,
}
struct Taken {
text: String,
total_lines: Option<usize>,
next_offset: Option<usize>,
}
impl Interval {
fn asked_for(call: &ToolCall) -> Result<Interval, AxError> {
let args = call.args.as_map();
let whole = |field: &'static str| -> Result<Option<u64>, AxError> {
match args.get(field) {
None => Ok(None),
Some(value) => value.as_u64().map(Some).ok_or_else(|| {
AxError::failure(
AxCode::InvalidArgs,
"read",
format!("`{field}` is not a whole number of lines"),
)
.with_recovery(
"pass `offset` and `limit` as non-negative integers, or leave them out \
to read from the start",
)
}),
}
};
let offset = whole("offset")?.unwrap_or(0);
let limit = whole("limit")?.map_or(LINE_CAP, |asked| {
u16::try_from(asked).map_or(LINE_CAP, |fits| fits.min(LINE_CAP))
});
if limit == 0 {
return Err(AxError::failure(
AxCode::InvalidArgs,
"read",
"`limit` is zero, which asks for no lines at all",
)
.with_recovery("pass a `limit` from 1 to 512, or leave it out"));
}
Ok(Interval { offset, limit })
}
fn cut(&self, text: &str) -> Taken {
let lines: Vec<&str> = text.split_inclusive('\n').collect();
let total = lines.len();
let start = match usize::try_from(self.offset) {
Ok(start) if start < total => start,
Ok(_) | Err(_) => {
return Taken {
text: String::new(),
total_lines: Some(total),
next_offset: None,
};
}
};
let ceiling = start.saturating_add(usize::from(self.limit)).min(total);
let budget = kernel::consts_policy::INTERVAL_CAP_BYTES;
let mut end = start;
let mut spent = 0usize;
while end < ceiling {
let Some(line) = lines.get(end) else { break };
let after = spent.saturating_add(line.len());
if after > budget && end > start {
break;
}
spent = after;
end = end.saturating_add(1);
}
let taken = lines.get(start..end).unwrap_or_default().concat();
if end < total {
Taken {
text: taken,
total_lines: Some(total),
next_offset: Some(end),
}
} else {
Taken {
text: taken,
total_lines: None,
next_offset: None,
}
}
}
}
pub struct ReadTool {
catalog: Arc<Mutex<Catalog>>,
reader: BoundReader,
meta: ToolMeta,
}
impl ReadTool {
pub const NAME: &'static str = "read";
pub fn new(
city_root: &Path,
catalog: Arc<Mutex<Catalog>>,
bound: ReadBound,
block_store: &Path,
) -> Result<ReadTool, AxError> {
let mut params = Map::new();
params.insert("type".to_owned(), Value::String("object".to_owned()));
let mut properties = Map::new();
let mut spec = Map::new();
spec.insert("type".to_owned(), Value::String("string".to_owned()));
spec.insert(
"description".to_owned(),
Value::String(
"a file path relative to the city root, or the name of a catalog entry".to_owned(),
),
);
properties.insert("path".to_owned(), Value::Object(spec));
for (name, description) in [
(
"offset",
"the first line to return, counted from 0; default 0",
),
(
"limit",
"how many lines to return, at most 512, which is also the default",
),
] {
let mut spec = Map::new();
spec.insert("type".to_owned(), Value::String("integer".to_owned()));
spec.insert(
"description".to_owned(),
Value::String(description.to_owned()),
);
properties.insert(name.to_owned(), Value::Object(spec));
}
params.insert("properties".to_owned(), Value::Object(properties));
params.insert(
"required".to_owned(),
Value::Array(vec![Value::String("path".to_owned())]),
);
Ok(ReadTool {
catalog,
reader: BoundReader::new(city_root, bound, block_store),
meta: ToolMeta {
name: ToolName::parse(Self::NAME)?,
disclosure: "Read a file by its path, or a skill by the name the catalog lists \
it under. A truncated answer states the total and the offset to \
continue from. A file comes back with its `version`, which `edit` \
takes as `base_version` and `plan finish` as evidence. Reading a directory or a missing file is refused."
.to_owned(),
params: Payload::new(params)?,
effect: Effect::Read,
cost_tier: CostTier::Light,
timeout: None,
render: RenderIntent::Generic,
temporal: Temporal::Timeless,
},
})
}
fn resolve(&self, asked: &str) -> Result<Found, AxError> {
if let Some(read) = locator::open_locator(asked, &self.reader, "read") {
let (_, bytes) = read?;
return String::from_utf8(bytes).map(Found::Text).map_err(|_| {
AxError::failure(AxCode::InvalidArgs, "read", format!("{asked} is not text"))
.with_recovery("read hands back text; these bytes are not UTF-8")
});
}
let catalog = self.catalog.lock().map_err(|_| {
AxError::failure(
AxCode::StorageFatal,
"read",
"the catalog was left locked by a thread that died",
)
.with_recovery(
"end this run and resume it: the catalog cannot be trusted again inside a \
process where a thread died holding it",
)
})?;
if let Some(expansion) = catalog.expand(asked) {
return match expansion {
Expansion::Skill { addr, .. } => {
let addr = kernel::Address::parse(&addr).map_err(|err| {
AxError::failure(
AxCode::ConfigInvalid,
"read",
format!("{asked} is shelved at {}", err.subject()),
)
.with_recovery(
"this building's reading room names a skill the city cannot address; \
the User has to fix the shelf",
)
})?;
package::open_document(&self.reader.city_root, &addr, asked)
}
Expansion::Said { text } => Ok(Found::Text(text)),
};
}
if let Some(inside) = package::open_in_package(&catalog, &self.reader.city_root, asked) {
return inside;
}
drop(catalog);
let city_root = &self.reader.city_root;
let spelled = super::chosen_path::within_city(city_root, asked, "read")?;
let addr = super::chosen_path::admit(&spelled, "read", &*self.reader.bound)?;
Ok(Found::File {
at: super::chosen_path::land(city_root, &addr, "read", &*self.reader.bound)?,
floor: Floor::of_address(city_root, &addr),
})
}
}
impl Tool for ReadTool {
fn meta(&self) -> &ToolMeta {
&self.meta
}
fn invoke(&self, call: &ToolCall) -> Result<ToolOutcome, AxError> {
if call.name != self.meta.name {
return Err(AxError::failure(
AxCode::InvalidArgs,
"read",
format!("call routed to the wrong tool: {}", call.name.as_str()),
)
.with_recovery(format!(
"call `{}`, the name this tool answers to",
self.meta.name.as_str()
)));
}
let asked = call
.args
.as_map()
.get("path")
.and_then(Value::as_str)
.ok_or_else(|| {
AxError::failure(
AxCode::InvalidArgs,
"read",
"missing string argument `path`",
)
.with_recovery("pass one string: a city-relative path, or a catalog name")
})?;
let mut out = Map::new();
out.insert("path".to_owned(), Value::String(asked.to_owned()));
let text = match self.resolve(asked)? {
Found::Text(text) => text,
Found::File { at, floor } => {
let text = miss::text_at(asked, at, &floor)?;
out.insert(
"version".to_owned(),
Value::String(super::version_of(text.as_bytes())),
);
text
}
};
let interval = Interval::asked_for(call)?;
let taken = interval.cut(&text);
if let Some(total) = taken.total_lines {
out.insert("total_lines".to_owned(), Value::Number(total.into()));
}
if let Some(next) = taken.next_offset {
out.insert("next_offset".to_owned(), Value::Number(next.into()));
}
out.insert("bytes".to_owned(), Value::Number(taken.text.len().into()));
out.insert("text".to_owned(), Value::String(taken.text));
Ok(ToolOutcome {
result: Payload::new(out)?,
attachments: Vec::new(),
})
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing,
reason = "test code"
)]
mod tests;