use polyc_llm::ToolSpec;
use serde::Deserialize;
use serde_json::json;
pub const CONVERSATION_FIND: &str = "conversation_find";
pub const CONVERSATION_READ_TURN: &str = "conversation_read_turn";
pub const CONVERSATION_READ_TOOL_RESULT: &str = "conversation_read_tool_result";
pub const CONVERSATION_RECENT_TURNS: &str = "conversation_recent_turns";
pub const CONVERSATION_LIST_TOOL_CALLS: &str = "conversation_list_tool_calls";
pub const ALL: &[&str] = &[
CONVERSATION_FIND,
CONVERSATION_READ_TURN,
CONVERSATION_READ_TOOL_RESULT,
CONVERSATION_RECENT_TURNS,
CONVERSATION_LIST_TOOL_CALLS,
];
pub const JOURNAL_REPLAY: &[&str] = &[
CONVERSATION_FIND,
CONVERSATION_READ_TURN,
CONVERSATION_READ_TOOL_RESULT,
];
pub const QUERY_ENGINE: &[&str] = &[CONVERSATION_RECENT_TURNS, CONVERSATION_LIST_TOOL_CALLS];
pub const RESULT_UNTRUSTED_KEY: &str = "untrusted";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Detail {
#[default]
Concise,
Full,
}
impl Detail {
#[must_use]
pub const fn is_full(self) -> bool {
matches!(self, Self::Full)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum FindMode {
#[default]
Relevance,
Pattern,
}
fn detail_property(what_full_adds: &str) -> serde_json::Value {
json!({
"type": "string",
"enum": ["concise", "full"],
"description": format!("\"concise\" (the default) returns just what answers the question. \"full\" {what_full_adds}"),
})
}
#[must_use]
pub fn all_specs() -> Vec<ToolSpec> {
vec![
find_spec(),
read_turn_spec(),
read_tool_result_spec(),
recent_turns_spec(),
list_tool_calls_spec(),
]
}
#[must_use]
pub fn find_spec() -> ToolSpec {
ToolSpec::new(
CONVERSATION_FIND,
"Find earlier messages in this conversation and get back the matching \
moments — a turn id and a short excerpt each. Use it when something \
was said before it scrolled out of view and you need to locate it \
again. `mode` picks how to look: \"relevance\" (the default) ranks by \
how many of your words each message shares, and is what you want for \
a topic or an idea; \"pattern\" treats the query as a regular \
expression, and is what you want for an exact shape — an id, a URL, a \
literal phrase. Pattern mode is case-insensitive unless the pattern \
opts out with (?-i), and uses Rust regex syntax, so backreferences \
and lookaround are not supported. `limit` is trimmed to 25, and \
leaving it out returns 5. Finding nothing is an ordinary empty \
result, not an error. When more matches exist than you asked for, \
`truncated` comes back true — raise `limit` or narrow the query. \
Searches this conversation and nothing else. To read a match in full, \
pass its turn id to conversation_read_turn; to see what tools ran \
rather than what was said, use conversation_list_tool_calls; for what \
you already know about this person from earlier conversations, use \
memory_recall.",
json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Words to look for, or a regular expression when mode is \"pattern\"."
},
"mode": {
"type": "string",
"enum": ["relevance", "pattern"],
"description": "\"relevance\" (the default) ranks by shared words; \"pattern\" matches the query as a regular expression."
},
"limit": {
"type": "integer",
"description": "Most matches to return (default 5, at most 25).",
"minimum": 1
},
"detail": detail_property(
"adds each match's position in the conversation, for ordering matches against each other."
)
},
"required": ["query"],
"additionalProperties": false
}),
)
.titled("Find something in this conversation")
.read_only()
.cacheable_approval()
}
#[must_use]
pub fn read_turn_spec() -> ToolSpec {
ToolSpec::new(
CONVERSATION_READ_TURN,
"Read one earlier turn of this conversation in full, given the turn id \
from a conversation_find match or a conversation_recent_turns row. Use \
it once you have located a moment and need its exact wording rather \
than the excerpt. A very long turn comes back with its middle \
shortened, and says so. A turn id that names nothing in this \
conversation is an error, not an empty answer — list valid ones with \
conversation_recent_turns. Reads this conversation and nothing else. \
For the recorded output of a tool call rather than the words of a \
turn, use conversation_read_tool_result.",
json!({
"type": "object",
"properties": {
"turn_id": {
"type": "string",
"description": "The turn to read, from a conversation_find match or a conversation_recent_turns row."
},
"detail": detail_property(
"adds the turn's position in the conversation, for ordering it against other turns."
)
},
"required": ["turn_id"],
"additionalProperties": false
}),
)
.titled("Read an earlier turn")
.read_only()
.cacheable_approval()
}
#[must_use]
pub fn read_tool_result_spec() -> ToolSpec {
ToolSpec::new(
CONVERSATION_READ_TOOL_RESULT,
"Read the recorded result of one earlier tool call in this \
conversation, given its tool call id. Use it when an older tool result \
was set aside to keep the conversation inside its working window and \
you need its content again. Returns the result exactly as it was \
recorded — the tool is not run again, so nothing is re-fetched, \
re-sent, or re-charged. A tool call id that names nothing here is an \
error, not an empty answer; list valid ones with \
conversation_list_tool_calls, which is also where to look if you only \
need to know WHETHER something ran rather than what it returned. Reads \
this conversation and nothing else.",
json!({
"type": "object",
"properties": {
"tool_call_id": {
"type": "string",
"description": "The tool call id shown on the result to read back, or from a conversation_list_tool_calls row."
}
},
"required": ["tool_call_id"],
"additionalProperties": false
}),
)
.titled("Read an earlier tool result")
.read_only()
.cacheable_approval()
}
#[must_use]
pub fn recent_turns_spec() -> ToolSpec {
ToolSpec::new(
CONVERSATION_RECENT_TURNS,
"List this conversation's most recent committed turns, newest first: \
the turn id, whether it completed or failed, the model, the tokens it \
spent, and when it started. Use it to get your bearings on what has \
happened recently — how many turns, which ones failed, what they cost \
— and to get a turn id you can pass to conversation_read_turn. \
`detail: \"full\"` adds the outcome: why a failed turn failed. \
`limit` is trimmed to 50, and \
leaving it out returns 20. Only committed turns appear; a turn still \
running has no row yet. Covers this conversation and nothing else. To \
find a turn by what was said in it rather than by when it happened, \
use conversation_find.",
json!({
"type": "object",
"properties": {
"limit": {
"type": "integer",
"description": "Most turns to return, newest first (default 20, at most 50).",
"minimum": 1
},
"detail": detail_property(
"adds each turn's log position and its outcome: a failed turn's failure kind and message."
)
},
"additionalProperties": false
}),
)
.titled("List this conversation's recent turns")
.read_only()
.cacheable_approval()
}
#[must_use]
pub fn list_tool_calls_spec() -> ToolSpec {
ToolSpec::new(
CONVERSATION_LIST_TOOL_CALLS,
"List the tool calls recorded in this conversation, newest first: the \
tool, the first 200 characters of the arguments it was called with, \
and whether a result was ever recorded for it. Use it to check \
whether something was already tried, and with what. The recorded \
result is never included here — to read one back, pass its tool call \
id to conversation_read_tool_result. `detail: \"full\"` adds that tool \
call id, the turn and position the call sits at, and the approval \
outcome when a human was asked. `tool_name` narrows the list to one \
tool by exact name; `limit` is trimmed to 100, and leaving it out \
returns 20. Neither argument can widen what is read. Covers this \
conversation and nothing else.",
json!({
"type": "object",
"properties": {
"tool_name": {
"type": "string",
"description": "Return only calls to this exact tool name. Leave it out to see every tool."
},
"limit": {
"type": "integer",
"description": "Most calls to return, newest first (default 20, at most 100).",
"minimum": 1
},
"detail": detail_property(
"adds the tool call id to read a result back with, the turn and log position of the call, and the approval outcome when a human was asked."
)
},
"additionalProperties": false
}),
)
.titled("List this conversation's tool calls")
.read_only()
.cacheable_approval()
}
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use super::*;
fn spec_named(name: &str) -> ToolSpec {
all_specs()
.into_iter()
.find(|spec| spec.name == name)
.unwrap_or_else(|| panic!("{name} must be advertised"))
}
#[test]
fn tool_names_are_stable() {
assert_eq!(CONVERSATION_FIND, "conversation_find");
assert_eq!(CONVERSATION_READ_TURN, "conversation_read_turn");
assert_eq!(
CONVERSATION_READ_TOOL_RESULT,
"conversation_read_tool_result"
);
assert_eq!(CONVERSATION_RECENT_TURNS, "conversation_recent_turns");
assert_eq!(CONVERSATION_LIST_TOOL_CALLS, "conversation_list_tool_calls");
let mut advertised: Vec<String> = all_specs().into_iter().map(|spec| spec.name).collect();
advertised.sort();
let mut expected: Vec<String> = ALL.iter().map(|n| (*n).to_owned()).collect();
expected.sort();
assert_eq!(
advertised, expected,
"ALL and all_specs() must name the same five tools"
);
}
#[test]
fn the_two_backend_subsets_partition_the_family() {
let mut both: Vec<&str> = JOURNAL_REPLAY.iter().chain(QUERY_ENGINE).copied().collect();
both.sort_unstable();
let mut all: Vec<&str> = ALL.to_vec();
all.sort_unstable();
assert_eq!(both, all, "every tool must have exactly one backend");
}
#[test]
fn every_tool_is_an_ungated_cacheable_read() {
for spec in all_specs() {
assert!(spec.read_only, "{} reads", spec.name);
assert!(!spec.destructive, "{} destroys nothing", spec.name);
assert!(
!spec.needs_approval,
"{} must not pause for a human: it reads a fixed scope a caller can bound but \
not change",
spec.name
);
assert!(spec.cacheable_approval, "{}", spec.name);
assert!(spec.title.is_some(), "{} carries a title", spec.name);
}
}
#[test]
fn no_conversation_read_is_open_world() {
for name in ALL {
assert!(
!spec_named(name).open_world,
"{name} must leave the recorded provenance verdict authoritative per call — \
`open_world` would override it unconditionally"
);
}
}
#[test]
fn schemas_are_closed_and_bound_their_limits() {
for spec in all_specs() {
assert_eq!(
spec.schema_json["additionalProperties"],
json!(false),
"{}'s schema must be closed",
spec.name
);
}
for name in [
CONVERSATION_FIND,
CONVERSATION_RECENT_TURNS,
CONVERSATION_LIST_TOOL_CALLS,
] {
let spec = spec_named(name);
assert_eq!(
spec.schema_json["properties"]["limit"]["minimum"],
json!(1),
"{name}'s limit must be at least 1"
);
assert_eq!(
spec.schema_json["properties"]["limit"]["type"],
json!("integer"),
"{name}'s limit is a whole number"
);
}
assert_eq!(
spec_named(CONVERSATION_FIND).schema_json["required"],
json!(["query"])
);
assert_eq!(
spec_named(CONVERSATION_READ_TURN).schema_json["required"],
json!(["turn_id"])
);
assert_eq!(
spec_named(CONVERSATION_READ_TOOL_RESULT).schema_json["required"],
json!(["tool_call_id"])
);
}
#[test]
fn the_enum_spellings_match_what_the_handlers_decode() {
assert_eq!(
spec_named(CONVERSATION_FIND).schema_json["properties"]["mode"]["enum"],
json!(["relevance", "pattern"])
);
for spelling in ["relevance", "pattern"] {
serde_json::from_value::<FindMode>(json!(spelling))
.unwrap_or_else(|e| panic!("{spelling} must decode as a FindMode: {e}"));
}
for name in [
CONVERSATION_FIND,
CONVERSATION_READ_TURN,
CONVERSATION_RECENT_TURNS,
CONVERSATION_LIST_TOOL_CALLS,
] {
assert_eq!(
spec_named(name).schema_json["properties"]["detail"]["enum"],
json!(["concise", "full"]),
"{name}"
);
}
for spelling in ["concise", "full"] {
serde_json::from_value::<Detail>(json!(spelling))
.unwrap_or_else(|e| panic!("{spelling} must decode as a Detail: {e}"));
}
assert_eq!(Detail::default(), Detail::Concise);
assert!(!Detail::default().is_full());
assert_eq!(FindMode::default(), FindMode::Relevance);
assert!(
spec_named(CONVERSATION_READ_TOOL_RESULT).schema_json["properties"]
.get("detail")
.is_none(),
"a recorded result has no concise form — it comes back whole or not at all"
);
}
#[test]
fn the_call_list_never_offers_a_recorded_result() {
let description = spec_named(CONVERSATION_LIST_TOOL_CALLS).description;
assert!(
description.contains("The recorded result is never included"),
"the copy must say the result is not included: {description}"
);
assert!(
description.contains(CONVERSATION_READ_TOOL_RESULT),
"and must point at the tool that does return one: {description}"
);
}
#[test]
fn the_find_and_recall_pair_point_at_each_other() {
assert!(
spec_named(CONVERSATION_FIND)
.description
.contains(crate::memory::MEMORY_RECALL),
"conversation_find must say where cross-conversation knowledge lives"
);
assert!(
crate::memory::recall_spec()
.description
.contains(CONVERSATION_FIND),
"memory_recall must say where this conversation's own words live"
);
}
#[test]
fn every_description_points_at_a_sibling() {
for spec in all_specs() {
let named: Vec<&str> = ALL
.iter()
.copied()
.filter(|name| *name != spec.name && spec.description.contains(name))
.collect();
assert!(
!named.is_empty(),
"{}'s description must say when to reach for a sibling instead: {}",
spec.name,
spec.description
);
}
}
}