//// Shared ceremony for read-only retrieval tools: one place for the JSON
//// argument schema, the decode-and-usage wrapper, and the transcript text
//// rendering and paging every such tool repeats. Searching lives in
//// `albedo/harness/search`.
import albedo/daemon/conversation
import albedo/daemon/store
import albedo/harness/compaction
import albedo/harness/extension
import albedo/openai_api/types
import gleam/dynamic/decode
import gleam/int
import gleam/json
import gleam/list
import gleam/option.{None}
import gleam/result
import gleam/string
/// An object schema whose properties are all simple named types.
pub fn schema(
required: List(String),
properties: List(#(String, String)),
) -> json.Json {
json.object([
#("type", json.string("object")),
#("additionalProperties", json.bool(False)),
#("required", json.array(required, json.string)),
#(
"properties",
json.object(
list.map(properties, fn(property) {
#(property.0, json.object([#("type", json.string(property.1))]))
}),
),
),
])
}
/// One tool whose decoded arguments reach `run` directly and whose answer is
/// text: the schema, the argument decoding, the usage reply for undecodable
/// arguments, and the no-op recover, written once. These reads are refusals
/// when they fail, never a reason to end the turn.
pub fn text(
name: String,
description: String,
strict: Bool,
required: List(String),
properties: List(#(String, String)),
decoder: decode.Decoder(arguments),
usage: String,
run: fn(extension.Context, arguments) -> Result(String, String),
) -> extension.Tool {
extension.Tool(
types.Tool(name, description, schema(required, properties), strict),
fn(context, arguments) {
case json.parse(arguments, decoder) {
Ok(decoded) ->
run(context, decoded)
|> result.map(extension.text)
|> result.map_error(extension.Refused)
Error(_) -> Ok(extension.text(usage))
}
},
fn(_) { None },
)
}
/// The text of a transcript input, as retrieval tools show it.
pub fn row_text(input: types.Input) -> String {
case input {
types.User(text) -> "[user]\n" <> text
types.UserImage(text, image) ->
"[user with " <> image_description(image) <> "]\n" <> text
types.Assistant(text) -> "[assistant]\n" <> text
types.ToolOutput(id, output, images) ->
"[tool "
<> id
<> "]\n"
<> output
<> string.concat(
list.map(images, fn(image) { "\n[" <> image_description(image) <> "]" }),
)
types.Replay(item) ->
"[provider output]\n" <> json.to_string(types.replay_json(item))
}
}
fn image_description(image: types.Image) -> String {
let #(mime, width, height, _) = types.image_meta(image)
mime <> " " <> int.to_string(width) <> "x" <> int.to_string(height)
}
/// `value` cut to `maximum` graphemes with an ellipsis.
pub fn excerpt(value: String, maximum: Int) -> String {
case string.length(value) > maximum {
True -> string.slice(value, 0, maximum) <> "…"
False -> value
}
}
/// The `next_offset` field of a paged answer: the next position while rows
/// remain, null at the end.
pub fn next_offset(next: Int, more: Bool) -> json.Json {
case more {
True -> json.int(next)
False -> json.null()
}
}
/// One text page of `session`'s durable transcript rows from `seq` on.
pub fn transcript_read(
ledger: store.Store,
session: String,
seq: Int,
offset: Int,
limit: Int,
) -> Result(json.Json, String) {
use _ <- result.try(compaction.require(
offset >= 0,
"transcript read offset must be nonnegative",
))
use snapshot <- result.try(conversation.snapshot(ledger, session))
use #(page, next, more) <- result.try(source_page(
ledger,
snapshot,
seq,
snapshot.upper,
"row",
offset,
limit,
))
Ok(
json.object([
#("seq", json.int(seq)),
#("offset", json.int(offset)),
#("content", json.string(page)),
#("next_offset", next_offset(next, more)),
]),
)
}
type TextPage {
TextPage(
offset: Int,
limit: Int,
position: Int,
pending: String,
kept: List(String),
more: Bool,
first: Bool,
)
}
/// Render a chronological source range without retaining its complete text.
/// Carry the final grapheme across fragments, since a trailing CR combines
/// with the next row's newline separator.
pub fn source_page(
ledger: store.Store,
snapshot: conversation.Snapshot,
first: Int,
last: Int,
label: String,
offset: Int,
limit: Int,
) -> Result(#(String, Int, Bool), String) {
let initial =
TextPage(offset, int.clamp(limit, 1, 8000), 0, "", [], False, True)
use page <- result.try(
conversation.fold_sources(
ledger,
snapshot,
first,
last,
initial,
fn(page, item) {
let separator = case page.first {
True -> ""
False -> "\n\n"
}
let text =
separator
<> "["
<> label
<> " #"
<> int.to_string(item.source.seq)
<> "]\n"
<> row_text(item.entry.input)
let page = consume(TextPage(..page, first: False), page.pending <> text)
case page.more {
True -> conversation.Stop(page)
False -> conversation.Continue(page)
}
},
),
)
let page = case page.pending {
"" -> page
pending -> keep_grapheme(page, pending)
}
let content = page.kept |> list.reverse |> string.concat
Ok(#(content, offset + string.length(content), page.more))
}
fn consume(page: TextPage, text: String) -> TextPage {
case string.pop_grapheme(text) {
Error(_) -> TextPage(..page, pending: "")
Ok(#(grapheme, "")) -> TextPage(..page, pending: grapheme)
Ok(#(grapheme, rest)) -> {
let page = keep_grapheme(page, grapheme)
case page.more {
True -> TextPage(..page, pending: "")
False -> consume(page, rest)
}
}
}
}
fn keep_grapheme(page: TextPage, grapheme: String) -> TextPage {
case page.position < page.offset {
True -> TextPage(..page, position: page.position + 1)
False ->
case page.position - page.offset < page.limit {
True ->
TextPage(..page, position: page.position + 1, kept: [
grapheme,
..page.kept
])
False -> TextPage(..page, more: True)
}
}
}