///|
/// Built-in adapters: Posoco-provided implementations of port traits.
///
/// These structs are concrete adapters, not extension contracts — they live
/// in the root package (not `src/port/`) so the port package contains only
/// the intentional `pub(open)` traits community extensions implement.
/// Extension authors may register their own hook implementations instead of
/// or alongside these.
// SystemPromptHook — Hook impl that assembles + injects the prompt
///|
/// Combines a fixed base prompt with contributor sections and prepends the
/// result as a System message. Empty sections are skipped.
pub(all) struct SystemPromptHook {
base_prompt : String
contributors : Array[SystemPromptSection]
}
///|
/// A contributor entry: manifest id (for section header) and the contributor.
pub(all) struct SystemPromptSection {
id : String
contributor : &@port.SystemPromptContributor
}
///|
pub fn SystemPromptSection::SystemPromptSection(
id~ : String,
contributor~ : &@port.SystemPromptContributor,
) -> SystemPromptSection {
{ id, contributor }
}
///|
pub fn SystemPromptHook::SystemPromptHook(
base_prompt~ : String,
contributors~ : Array[SystemPromptSection],
) -> SystemPromptHook {
{ base_prompt, contributors }
}
///|
/// Assemble base + non-empty contributor sections with `id:` headers.
pub fn SystemPromptHook::assemble(self : SystemPromptHook) -> String {
let sections : Array[String] = [self.base_prompt]
for s in self.contributors {
let text = s.contributor.system_prompt()
if text != "" {
sections.push(s.id + ":\n" + text)
}
}
sections.join("\n\n")
}
///|
/// Idempotent injection: replaces the existing SystemMessage at index 0
/// (structural detection, not text-marker based) so repeated hook runs
/// do not accumulate.
pub impl @port.Hook for SystemPromptHook with fn before_model(
self : SystemPromptHook,
messages : Array[@kernel.Message],
) -> Array[@kernel.Message] raise @port.HookAbort {
let assembled = self.assemble()
let sys_msg : @kernel.Message = @kernel.SystemMessage(content=[
@kernel.Text(assembled),
])
if messages.length() == 0 {
return [sys_msg]
}
match messages[0] {
@kernel.SystemMessage(..) => {
let new_messages : Array[@kernel.Message] = [sys_msg]
for i in 1.. {
let new_messages : Array[@kernel.Message] = [sys_msg]
for m in messages {
new_messages.push(m)
}
new_messages
}
}
}
// MemoryRetrievalHook
///|
/// MemoryRetrievalHook: Hook (before_model) that retrieves durable memory
/// and injects as a SystemMessage (after the system-prompt). Best-effort,
/// non-fatal — a search failure never aborts the turn; messages pass
/// through unchanged and the failure is reported through `on_failure`
/// (Agent wires this to the `Custom(source="posoco.core",
/// label="secondary_failure")` observer event).
pub(all) struct MemoryRetrievalHook {
memories : Array[&@port.MemoryPort]
/// Max entries per search. Low default keeps context short.
top_k : Int
/// Invoked with a sanitized reason when a `MemoryPort::search` fails.
/// The failure is still swallowed (messages pass through unchanged);
/// this channel exists so the swallow stays observable.
on_failure : (String) -> Unit
}
///|
pub fn MemoryRetrievalHook::MemoryRetrievalHook(
memories~ : Array[&@port.MemoryPort],
top_k? : Int = 5,
on_failure? : (String) -> Unit,
) -> MemoryRetrievalHook {
let handler : (String) -> Unit = match on_failure {
Some(f) => f
None => fn(_reason) { () }
}
{ memories, top_k, on_failure: handler }
}
///|
/// Truncate and flatten an adapter-controlled error string before it can
/// reach an event payload (64 chars, no newlines).
fn memory_safe_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
}
}
///|
/// Marker prefix for idempotent detection of existing memory messages.
let memory_marker : String = "[MEMORY]"
///|
/// Build a SystemMessage from retrieved entries. Returns `None` if empty.
fn MemoryRetrievalHook::build_memory_message(
_self : MemoryRetrievalHook,
entries : Array[@types.MemoryEntry],
) -> @kernel.Message? {
if entries.is_empty() {
return None
}
let lines : Array[String] = [memory_marker]
for entry in entries {
lines.push("- " + entry.content)
}
Some(@kernel.SystemMessage(content=[@kernel.Text(lines.join("\n"))]))
}
///|
/// Extract query from the last UserMessage. Returns `None` if absent.
fn MemoryRetrievalHook::extract_query(
_self : MemoryRetrievalHook,
messages : Array[@kernel.Message],
) -> String? {
let mut i = messages.length() - 1
while i >= 0 {
match messages[i] {
@kernel.UserMessage(content~) => {
for c in content {
match c {
@kernel.Text(s) => return Some(s)
_ => continue
}
}
return None
}
_ => ()
}
i = i - 1
}
None
}
///|
/// Search every MemoryPort and merge results. Failures are reported via
/// `on_failure` and swallowed — the turn continues without memory context.
fn MemoryRetrievalHook::search_all(
self : MemoryRetrievalHook,
query : String,
) -> Array[@types.MemoryEntry] {
let out : Array[@types.MemoryEntry] = []
for mem in self.memories {
let q : @types.MemoryQuery = {
query,
top_k: self.top_k,
threshold: None,
filter: Map::from_array([]),
}
let entries : Array[@types.MemoryEntry] = mem.search(q) catch {
err => {
(self.on_failure)(memory_safe_label(err.to_string()))
[]
}
}
for e in entries {
out.push(e)
}
}
out
}
///|
/// Idempotent injection: replace existing memory SystemMessage or insert
/// after the system-prompt message.
fn MemoryRetrievalHook::inject(
_self : MemoryRetrievalHook,
messages : Array[@kernel.Message],
memory_msg : @kernel.Message,
) -> Array[@kernel.Message] {
let out : Array[@kernel.Message] = []
let mut injected = false
for m in messages {
let is_memory_msg = match m {
@kernel.SystemMessage(content~) =>
match content {
[@kernel.Text(s), ..] => s.has_prefix(memory_marker)
_ => false
}
_ => false
}
if is_memory_msg {
out.push(memory_msg)
injected = true
} else {
out.push(m)
}
}
if !injected {
let final_out : Array[@kernel.Message] = []
if out.length() > 0 {
match out[0] {
@kernel.SystemMessage(..) => {
final_out.push(out[0])
final_out.push(memory_msg)
for i in 1.. {
final_out.push(memory_msg)
for m in out {
final_out.push(m)
}
}
}
return final_out
}
out.push(memory_msg)
}
out
}
///|
pub impl @port.Hook for MemoryRetrievalHook with fn before_model(
self : MemoryRetrievalHook,
messages : Array[@kernel.Message],
) -> Array[@kernel.Message] raise @port.HookAbort {
match self.extract_query(messages) {
None => return messages
Some(query) => {
let entries = self.search_all(query)
match self.build_memory_message(entries) {
None => return messages
Some(memory_msg) => return self.inject(messages, memory_msg)
}
}
}
}
// UiRenderHook
///|
/// UiRenderHook: Hook (on_post_event) that projects events into UI render
/// intents. Non-raising by contract (both `Hook::on_post_event` and
/// `UiPort::render` are non-raising), so there is nothing to propagate.
pub(all) struct UiRenderHook {
ui : &@port.UiPort
}
///|
pub fn UiRenderHook::UiRenderHook(ui~ : &@port.UiPort) -> UiRenderHook {
{ ui, }
}
///|
/// Render a tool-completed stage to the Notice slot.
fn UiRenderHook::render_tool_completed(
self : UiRenderHook,
call : @kernel.ToolCall,
outcome : @kernel.ToolOutcome,
) -> Unit {
let label = call.name.to_string()
let outcome_tag = outcome.to_string()
let intent : @port.UiRender = {
slot: UiSlot::Notice,
key: "tool_" + call.call_id.to_string(),
title: Some(label),
body: UiBody::Text(outcome_tag),
ttl_ms: None,
}
self.ui.render(intent)
}
///|
/// Render a failure stage to the Status slot.
fn UiRenderHook::render_failure(
self : UiRenderHook,
key : String,
reason : String,
) -> Unit {
let intent : @port.UiRender = {
slot: UiSlot::Status,
key,
title: None,
body: UiBody::Text(reason),
ttl_ms: None,
}
self.ui.render(intent)
}
///|
pub impl @port.Hook for UiRenderHook with fn on_post_event(
self : UiRenderHook,
stage : @port.HookStage,
) -> Unit {
match stage {
// ModelCompleted is intentionally NOT rendered: it is an intermediate
// state in every tool→model round-trip, and a persistent "model
// responded" status misleads the user into thinking the turn stalled
// (QA issue: status shows model response while tools feed back to the
// LLM). Hosts own progress/status; this hook surfaces tool outcomes and
// typed failures only.
@port.ModelCompleted(..) => ()
@port.ToolCompleted(call~, outcome~) =>
self.render_tool_completed(call, outcome)
@port.ToolFailed(call~, reason~) =>
self.render_failure(
"tool_fail_" + call.call_id.to_string(),
"tool " + call.name.to_string() + " failed: " + reason,
)
@port.ModelFailed(failure~) =>
self.render_failure("model_fail", "model failed: " + failure.to_string())
}
}
// NoopUiPort
///|
/// Default UiPort for headless hosts. `request` raises `Unsupported`.
pub(all) struct NoopUiPort {}
///|
pub fn NoopUiPort::NoopUiPort() -> NoopUiPort {
NoopUiPort::{ }
}
///|
pub impl @port.UiPort for NoopUiPort with fn ui_descriptor(_self) -> @port.UiDescriptor {
@port.UiDescriptor::empty()
}
///|
pub impl @port.UiPort for NoopUiPort with fn render(_self, _intent) -> Unit {
}
///|
pub impl @port.UiPort for NoopUiPort with fn request(_self, _req) -> @port.UiResponse {
raise @error.UiError::Unsupported(detail="NoopUiPort has no UI backend")
}
///|
pub extend NoopUiPort with @port.UiPort::{ui_descriptor, render, request}
// CompositeUiPort
///|
/// Fan-out UiPort: `render` delivers to every contributor; `request` tries
/// each in order, first non-`Unsupported` wins.
pub(all) struct CompositeUiPort {
contributors : Array[&@port.UiPort]
}
///|
pub fn CompositeUiPort::CompositeUiPort(
contributors : Array[&@port.UiPort],
) -> CompositeUiPort {
{ contributors, }
}
///|
pub fn CompositeUiPort::contributors(
self : CompositeUiPort,
) -> Array[&@port.UiPort] {
self.contributors.copy()
}
///|
/// Concatenate autocomplete sources in registration order.
pub impl @port.UiPort for CompositeUiPort with fn ui_descriptor(self) -> @port.UiDescriptor {
let sources : Array[@port.AutocompleteSource] = []
for c in self.contributors {
let d = c.ui_descriptor()
for s in d.autocomplete_sources {
sources.push(s)
}
}
{ autocomplete_sources: sources }
}
///|
/// Broadcast to every contributor in registration order. Best-effort.
pub impl @port.UiPort for CompositeUiPort with fn render(self, intent) -> Unit {
for c in self.contributors {
c.render(intent)
}
}
///|
/// Try each contributor in order. First non-`Unsupported` result wins;
/// all-`Unsupported` raises `Unsupported`.
pub impl @port.UiPort for CompositeUiPort with fn request(self, req) -> @port.UiResponse {
let mut last_unsupported_detail = "no contributors"
for c in self.contributors {
let result : Result[@port.UiResponse, @error.UiError] = Ok(c.request(req)) catch {
e => Err(e)
}
match result {
Ok(resp) => return resp
Err(@error.UiError::Unsupported(detail~)) =>
last_unsupported_detail = detail
Err(other) => raise other
}
}
raise @error.UiError::Unsupported(
detail="no contributor supported the request; last detail=" +
last_unsupported_detail,
)
}
///|
pub extend CompositeUiPort with @port.UiPort::{ui_descriptor, render, request}