///|
/// 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 — PipelineHook impl that assembles + injects the prompt
///|
/// Combines a fixed base prompt with contributor sections and prepends the
/// result as a single System message at index 0. Empty sections are skipped.
///
/// The assembled prompt is cached lazily on the first call to `before_model`.
/// Laziness matters because extension `Lifecycle::on_start` callbacks run
/// inside the first `run_single_turn` but before the first `before_model`, so
/// contributors whose state is initialized in `on_start` must be read after
/// that point. After caching, the text is frozen for the lifetime of the hook.
pub(all) struct SystemPromptHook {
base_prompt : String
contributors : Array[SystemPromptSection]
mut assembled : String?
}
///|
/// 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, assembled: None, }
}
///|
/// Assemble base + non-empty contributor sections with `id:` headers.
/// Sections are separated by a blank line (`\n\n`). The result is empty when
/// the base prompt and every contributor return empty strings.
pub fn SystemPromptHook::assemble(self : SystemPromptHook) -> String {
let sections : Array[String] = []
if self.base_prompt != "" {
sections.push(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")
}
///|
/// Inject one stable SystemMessage at index 0.
///
/// Semantics:
/// 1. If the assembled prompt is empty, return `messages` unchanged.
/// 2. If `messages` is empty, return `[SystemMessage(assembled)]`.
/// 3. If `messages[0]` is a SystemMessage with the exact same text, return
/// the same array instance (no rewrite, no journal noise).
/// 4. If `messages[0]` is a SystemMessage with different text, replace it.
/// 5. Otherwise prepend the new SystemMessage.
pub impl @port.PipelineHook for SystemPromptHook with fn before_model(
self : SystemPromptHook,
messages : Array[@kernel.Message],
) -> Array[@kernel.Message] raise @port.HookAbort {
let assembled = match self.assembled {
Some(text) => text
None => {
let text = self.assemble()
self.assembled = Some(text)
text
}
}
if assembled == "" {
return messages
}
let sys_msg : @kernel.Message = @kernel.SystemMessage(content=[
@kernel.Text(assembled),
])
if messages.length() == 0 {
return [sys_msg]
}
match messages[0] {
@kernel.SystemMessage(content~) => {
match content {
[@kernel.Text(existing), ..] if existing == assembled => return messages
_ => ()
}
let new_messages : Array[@kernel.Message] = [sys_msg]
new_messages.append(messages[1:])
new_messages
}
_ => {
let new_messages : Array[@kernel.Message] = [sys_msg]
new_messages.append(messages)
new_messages
}
}
}
// UiRenderHook
///|
/// UiRenderHook: PipelineHook (on_post_event) that projects events into UI render
/// intents. Non-raising by contract (both `PipelineHook::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.PipelineHook 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}