///|
/// Canonical transcript message types for the functional Kernel.
/// Tool calls, reasoning, and finish_reason live only on `AssistantMessage`.
/// Pure data — no IO, no trait references, no async.
///|
/// Canonical content block. `Text` is the common case; `Image` carries a
/// stable media-type identifier plus opaque payload data (base64, URL, or an
/// adapter-managed handle — the Kernel does not interpret it).
pub(all) enum Content {
Text(String)
Image(media_type~ : String, data~ : String)
} derive(Eq, Debug)
///|
pub impl Show for Content with fn to_string(self) -> String {
match self {
Text(s) => "Text(\{s.length()} chars)"
Image(media_type~, data~) =>
"Image(\{media_type}, \{data.length()} bytes-suffix)"
}
}
///|
/// Stable identifier for a single tool call requested by the assistant. The
/// reducer and scheduler only ever compare/store this string; it is opaque to
/// the Kernel and is produced by the model adapter or by the host.
pub(all) struct CallId {
value : String
} derive(Eq, Hash, Debug)
///|
/// Construct a `CallId`. Empty strings are rejected — an empty id would make
/// correlation ambiguous. The Kernel invariant treats a missing/empty call id
/// in a streamed completion as `ModelParseFailure`, not as a usable call.
pub fn CallId::new(value : String) -> Result[CallId, String] {
if value == "" {
Err("CallId must be non-empty")
} else {
Ok(CallId::{ value, })
}
}
///|
/// Internal constructor used by Kernel tests and by callers that have already
/// validated the string. Production callers should prefer `CallId::new` and
/// handle the typed rejection.
pub fn CallId::unchecked(value : String) -> CallId {
CallId::{ value, }
}
///|
pub fn CallId::to_string(self : CallId) -> String {
self.value
}
///|
/// A tool name as it appears in the catalog (call-visible name). The Kernel
/// uses this only for lookup in the catalog snapshot; it never re-parses or
/// re-normalizes it.
pub(all) struct ToolName {
value : String
} derive(Eq, Hash, Debug)
///|
pub fn ToolName::new(value : String) -> Result[ToolName, String] {
if value == "" {
Err("ToolName must be non-empty")
} else {
Ok(ToolName::{ value, })
}
}
///|
pub fn ToolName::unchecked(value : String) -> ToolName {
ToolName::{ value, }
}
///|
pub fn ToolName::to_string(self : ToolName) -> String {
self.value
}
///|
/// Tool arguments as parsed JSON. This is the post-parsing representation: if
/// the model stream produced malformed JSON, the reducer has already terminated
/// the Run with `ModelParseFailure` before this type is constructed. Therefore
/// any `ToolCall` reaching the scheduler has well-formed arguments,
/// but the arguments may still fail the catalog schema (handled separately as
/// `NotExecuted(SchemaMismatch)`).
pub(all) struct ToolCall {
call_id : CallId
name : ToolName
arguments : Json
} derive(Eq, Debug)
///|
pub impl Show for ToolCall with fn to_string(self) -> String {
let args_summary = match self.arguments {
Object(_) => "object"
Array(_) => "array"
_ => "scalar"
}
"ToolCall(call_id=\{self.call_id.to_string()}, name=\{self.name.to_string()}, args=\{args_summary})"
}
///|
/// Reasoning trace produced by the assistant (e.g. DeepSeek deepseek-reasoner
/// chain-of-thought, OpenAI o-series reasoning content). The Kernel never
/// interprets the content; it stores and replays it. `None` means the assistant
/// produced no reasoning for this step.
///
/// `raw` is a provider-defined replay payload: OpenAI Responses API
/// reasoning items must be replayed verbatim when the host manually manages
/// conversation state (per the official reasoning guide), so the adapter
/// stores the raw items here. The kernel never interprets it; providers that
/// replay plain `content` text (DeepSeek, Kimi, OpenAI-compatible) leave it
/// `None`.
pub(all) struct Reasoning {
content : String
raw : Json?
} derive(Eq, Debug)
///|
pub impl Show for Reasoning with fn to_string(self : Reasoning) -> String {
"Reasoning(\{self.content.length()} chars)"
}
///|
/// Canonical conversation message. The role determines which payload is
/// legal, making invalid role/payload combinations unrepresentable.
///
/// Invariants:
/// - `SystemMessage` / `UserMessage` never carry tool calls.
/// - `AssistantMessage` is the ONLY variant that carries tool calls and the
/// ONLY variant that carries reasoning.
/// - `ToolMessage` is the ONLY variant that carries a `call_id` correlation
/// and a `ToolOutcome`. It never carries tool calls.
pub(all) enum Message {
SystemMessage(content~ : Array[Content])
UserMessage(content~ : Array[Content])
AssistantMessage(
content~ : Array[Content],
tool_calls~ : Array[ToolCall],
reasoning~ : Reasoning?,
finish_reason~ : FinishReason
)
ToolMessage(call_id~ : CallId, tool_name~ : ToolName, outcome~ : ToolOutcome)
} derive(Eq, Debug)
///|
/// Why the assistant finished a step. It is owned by the assistant message
/// (per ADR §2.11: single source of truth).
///
/// `Length` is preserved as a distinct variant because the reducer must NOT
/// emit `ExecuteTool` effects after a length-truncated completion, even if the
/// provider tried to stream partial tool calls.
pub(all) enum FinishReason {
Stop
Length
ToolCalls
Other(String)
} derive(Eq, Debug)
///|
pub impl Show for FinishReason with fn to_string(self : FinishReason) -> String {
match self {
Stop => "Stop"
Length => "Length"
ToolCalls => "ToolCalls"
Other(s) => "Other(\{s})"
}
}
///|
/// Discriminator for the reason a tool call was NOT executed even though the
/// model requested it (ADR §2.7).
///
/// - `UnknownTool`: the requested tool name is not present in the catalog
/// snapshot that was handed to the `CallModel` effect.
/// - `SchemaMismatch`: the model's arguments parsed successfully but failed
/// validation against the tool's input schema. The diagnostic carries a safe
/// JSON path and type expectation; it never includes the raw argument
/// payload.
///
/// Malformed-argument cases never reach this variant — they are
/// `ModelParseFailure` at the reducer boundary (ADR §2.4).
pub(all) enum NotExecutedReason {
UnknownTool
SchemaMismatch(json_path~ : String, expected~ : String, actual~ : String)
} derive(Eq, Debug)
///|
pub impl Show for NotExecutedReason with fn to_string(self : NotExecutedReason) -> String {
match self {
UnknownTool => "UnknownTool"
SchemaMismatch(json_path~, expected~, actual~) =>
"SchemaMismatch(path=\{json_path}, expected=\{expected}, actual=\{actual})"
}
}
///|
/// Outcome of a single tool invocation (ADR §2.4, §2.11).
///
/// Variants:
/// - `Success`: tool ran and returned structured data. `structured` is the
/// model-facing JSON payload; `content` is the human-readable mirror used by
/// the transcript.
/// - `ToolReportedError`: tool ran and reported a business-level failure. The
/// Run continues; the failure is fed back to the model as a normal tool
/// message.
/// - `RuntimeFailure`: tool did not run to completion — adapter raised a
/// `RuntimeError`. Still non-terminal for the Run.
/// - `NotExecuted`: model requested a tool that was not executed (unknown name
/// or parseable-but-schema-invalid args). The original call id is preserved
/// so the model can correlate the feedback.
///
/// There is deliberately no boolean `is_error` field here. The variant tag IS
/// the error status.
pub(all) enum ToolOutcome {
Success(content~ : String, structured~ : Json?)
ToolReportedError(content~ : String, structured~ : Json?)
RuntimeFailure(error_category~ : String, message~ : String)
NotExecuted(reason~ : NotExecutedReason, original_call_id~ : CallId)
} derive(Eq, Debug)
///|
/// Whether the outcome represents a non-success path (any variant other than
/// `Success`). Product adapters may use this for presentation, while canonical
/// control flow pattern-matches the outcome exhaustively.
pub fn ToolOutcome::is_failure(self : ToolOutcome) -> Bool {
match self {
Success(..) => false
_ => true
}
}
///|
/// Single-line summary that excludes raw payloads. Used by `Show` and by any
/// debug/log surface, so that prompts and tool arguments never leak.
pub fn ToolOutcome::summary(self : ToolOutcome) -> String {
match self {
Success(content~, structured~) =>
"Success(\{content.length()} chars, structured=\{structured is Some(_)})"
ToolReportedError(content~, ..) =>
"ToolReportedError(\{content.length()} chars)"
RuntimeFailure(error_category~, message~) =>
"RuntimeFailure(\{error_category}, \{safe_outcome_label(message)})"
NotExecuted(reason~, original_call_id~) =>
"NotExecuted(\{reason.to_string()}, call_id=\{original_call_id.to_string()})"
}
}
///|
pub impl Show for ToolOutcome with fn to_string(self : ToolOutcome) -> String {
self.summary()
}
///|
/// Internal bounded single-line label helper for safe diagnostics.
fn safe_outcome_label(value : String) -> String {
let chars : Array[Char] = []
let mut truncated = false
for char in value {
if chars.length() >= 64 {
truncated = true
break
}
match char {
'\n' | '\r' => chars.push(' ')
other => chars.push(other)
}
}
let label = String::from_array(chars)
if truncated {
label + "...(truncated)"
} else {
label
}
}
///|
/// Canonical conversation transcript. Every transition produces a fresh array
/// (the reducer never mutates caller-owned arrays, ADR §2.10). The reducer is
/// the only writer.
pub(all) struct Transcript {
messages : Array[Message]
} derive(Eq, Debug)
///|
pub fn Transcript::empty() -> Transcript {
{ messages: [] }
}
///|
pub fn Transcript::from_array(messages : Array[Message]) -> Transcript {
// Defensive copy: the transcript must be reducer-owned. Callers that pass
// an array they continue to mutate will not affect this transcript.
{ messages: messages.copy() }
}
///|
/// Returns a new transcript with `message` appended. The original transcript
/// is not mutated (ADR §2.10).
pub fn Transcript::append(self : Transcript, message : Message) -> Transcript {
let next = self.messages.copy()
next.push(message)
{ messages: next }
}
///|
/// Returns a new transcript with `messages` appended in order.
pub fn Transcript::append_all(
self : Transcript,
messages : Array[Message],
) -> Transcript {
let next = self.messages.copy()
for m in messages {
next.push(m)
}
{ messages: next }
}
///|
/// Returns the number of messages.
pub fn Transcript::length(self : Transcript) -> Int {
self.messages.length()
}
///|
/// Read-only snapshot of the message array. Returns a copy so callers cannot
/// mutate the reducer-owned transcript.
pub fn Transcript::messages_snapshot(self : Transcript) -> Array[Message] {
self.messages.copy()
}