///|
/// Agent-level runtime config: provider-agnostic tuning and run budget.
/// `max_tool_rounds` bounds the tool rounds of one turn: `Some(n)` allows n
/// full rounds and rejects the (n+1)-th batch atomically; `Some(0)` forbids
/// tool execution; `None` is unbounded (the recommended product default —
/// the human abort and compaction govern loop length, matching peer coding
/// agents).
pub(all) struct AgentConfig {
  max_tool_rounds : Int?
  temperature : Double?
  max_output_tokens : Int?
  model_context_window : Int?
}

///|
/// Encode the provider-agnostic chat options for `RunPolicy.call_options`.
/// Provider-specific configuration belongs to the ModelPort extension and
/// never crosses the canonical HostRuntime seam.
fn AgentConfig::to_chat_options_to_json(self : AgentConfig) -> Json {
  let fields : Map[String, Json] = Map::from_array([])
  match self.temperature {
    Some(t) => fields["temperature"] = Json::number(t)
    None => ()
  }
  match self.max_output_tokens {
    Some(n) => fields["max_output_tokens"] = Json::number(n.to_double())
    None => ()
  }
  Json::object(fields)
}

///|
/// Internal routing table: tool name → owning provider. Built during Agent::new.
priv struct ToolRouting {
  tool_map : Map[String, &@port.ToolProvider]
  providers : Array[&@port.ToolProvider]
}

///|
fn safe_error_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
  }
}

///|
fn agent_snapshot_json(value : Json) -> Json {
  match value {
    Json::Array(items) => Json::array(items.map(agent_snapshot_json))
    Json::Object(fields) => {
      let snapshot : Map[String, Json] = Map::from_array([])
      for key in fields.keys() {
        snapshot[key] = agent_snapshot_json(fields[key])
      }
      Json::object(snapshot)
    }
    scalar => scalar
  }
}

///|
fn agent_snapshot_tool_call(call : @kernel.ToolCall) -> @kernel.ToolCall {
  {
    call_id: call.call_id,
    name: call.name,
    arguments: agent_snapshot_json(call.arguments),
  }
}

///|
fn agent_snapshot_content(content : @kernel.Content) -> @kernel.Content {
  match content {
    @kernel.Text(text) => @kernel.Text(text)
    @kernel.Image(media_type~, data~) => @kernel.Image(media_type~, data~)
  }
}

///|
fn agent_snapshot_message(message : @kernel.Message) -> @kernel.Message {
  match message {
    @kernel.SystemMessage(content~) =>
      @kernel.SystemMessage(content=content.map(agent_snapshot_content))
    @kernel.UserMessage(content~) =>
      @kernel.UserMessage(content=content.map(agent_snapshot_content))
    @kernel.AssistantMessage(content~, tool_calls~, reasoning~, finish_reason~) =>
      @kernel.AssistantMessage(
        content=content.map(agent_snapshot_content),
        tool_calls=tool_calls.map(agent_snapshot_tool_call),
        reasoning~,
        finish_reason~,
      )
    @kernel.ToolMessage(call_id~, tool_name~, outcome~) =>
      @kernel.ToolMessage(call_id~, tool_name~, outcome~)
  }
}

///|
fn agent_snapshot_messages(
  messages : Array[@kernel.Message],
) -> Array[@kernel.Message] {
  messages.map(agent_snapshot_message)
}

///|
fn agent_snapshot_metadata(metadata : Map[String, Json]) -> Map[String, Json] {
  let snapshot : Map[String, Json] = Map::from_array([])
  for key in metadata.keys() {
    snapshot[key] = agent_snapshot_json(metadata[key])
  }
  snapshot
}

///|
fn agent_snapshot_session(
  messages : Array[@kernel.Message],
  metadata : Map[String, Json],
) -> @types.Session {
  {
    messages: agent_snapshot_messages(messages),
    metadata: agent_snapshot_metadata(metadata),
  }
}

///|
fn agent_snapshot_tool_outcome(
  outcome : @kernel.ToolOutcome,
) -> @kernel.ToolOutcome {
  match outcome {
    @kernel.Success(content~, structured~) =>
      @kernel.Success(
        content~,
        structured=match structured {
          Some(j) => Some(agent_snapshot_json(j))
          None => None
        },
      )
    @kernel.ToolReportedError(content~, structured~) =>
      @kernel.ToolReportedError(
        content~,
        structured=match structured {
          Some(j) => Some(agent_snapshot_json(j))
          None => None
        },
      )
    @kernel.RuntimeFailure(error_category~, message~) =>
      @kernel.RuntimeFailure(error_category~, message~)
    @kernel.NotExecuted(reason~, original_call_id~) =>
      @kernel.NotExecuted(reason~, original_call_id~)
  }
}

///|
fn tool_provenance(tool : @kernel.ToolDef) -> String {
  match tool.provenance {
    Some(source) if source != "" =>
      "ToolDef.provenance='\{safe_error_label(source)}'"
    _ =>
      "unlabeled provider declaring '\{safe_error_label(tool.name.to_string())}' (set ToolDef.provenance to a stable provider id)"
  }
}

///|
/// Build the tool routing table. Raises CompositionError::ToolCollision on
/// the FIRST name collision (fail-fast), rather than last-wins. The
/// collision message identifies the tool name and a provenance hint for each
/// conflicting provider (ToolDef.provenance if present, else an actionable marker).
fn build_tool_routing(
  tools : Array[&@port.ToolProvider],
) -> ToolRouting raise @error.CompositionError {
  let tool_map : Map[String, &@port.ToolProvider] = Map::from_array([])
  let owner_source : Map[String, String] = Map::from_array([])
  for provider in tools {
    let tool_defs = provider.list_tools()
    for tool in tool_defs {
      let name = tool.name.to_string()
      let provenance = tool_provenance(tool)
      if tool_map.contains(name) {
        // T06-B: fail-fast on collision. The error names the tool and both
        // sources so the caller can locate the conflict without guessing.
        let prev_source = match owner_source.get(name) {
          Some(s) => s
          None => "(unknown)"
        }
        raise @error.CompositionError::ToolCollision(
          safe_error_label(name),
          "tool collision; first declaration: \{prev_source}; conflicting declaration: \{provenance}",
          manifests=[],
        )
      }
      tool_map[name] = provider
      owner_source[name] = provenance
    }
  }
  { tool_map, providers: tools }
}

///|
/// Private mutable implementation owned by the Agent facade.
priv struct AgentRuntime {
  puppet : @puppetry.Puppet
  observers : Array[&@port.Observer]
  sessions : Array[&@port.SessionStore]
  lifecycle : Array[&@port.Lifecycle]
  commands : Array[&@port.CommandPort]
  builtin_command_port : BuiltinCommandPort
  control : @runtime.RuntimeControl
  config : AgentConfig
  /// Host-owned versioned catalog (experimental runtime seam). `None` keeps
  /// the static composition-time snapshot for the Agent's lifetime.
  catalog_source : &@runtime.CatalogSource?
  /// The source revision last read (0 = no source / not yet read).
  mut last_catalog_revision : Int
  /// Next monotonic catalog version to assign on a successful refresh.
  /// Composition builds version 1; refreshes hand out 2, 3, ...
  mut next_catalog_version : Int
  mut next_run_seq : Int
  mut shutdown_started : Bool
  mut shutdown_complete : Bool
  mut next_lifecycle_shutdown : Int
}

///|
/// Public deep module for agent developers. The runtime representation is a
/// private handle so canonical execution machinery never enters the interface.
pub struct Agent {
  priv runtime : AgentRuntime
}

///|
/// Shared composition path for both public constructors. `host_override`
/// is `Some(shimmed runtime)` for `Agent::with_runtime`; `None` builds the
/// default `PortRuntime` over the aggregated ports and shims it through the
/// same path — there is exactly one way to assemble an Agent.
///
/// `catalog_source` (experimental runtime seam): when `Some`, the tool
/// catalog is owned by the source — built from its definitions at
/// composition and rebuilt at prompt boundaries when its revision changes.
/// When `None`, the catalog is the static snapshot of the aggregated
/// `ToolProvider` declarations.
fn AgentRuntime::compose(
  agg : AggregatedPorts,
  config : AgentConfig,
  ui_projection : Bool,
  host_override : &@kernel_exec.HostRuntime?,
  catalog_source : &@runtime.CatalogSource?,
) -> Agent raise @error.CompositionError {
  match config.max_tool_rounds {
    Some(value) if value < 0 =>
      raise @error.CompositionError::ManifestSchemaError(
        manifest_id="agent.config",
        detail="max_tool_rounds must be non-negative",
      )
    _ => ()
  }
  match config.model_context_window {
    Some(value) if value <= 0 =>
      raise @error.CompositionError::ManifestSchemaError(
        manifest_id="agent.config",
        detail="model_context_window must be positive",
      )
    _ => ()
  }
  if agg.sessions.is_empty() {
    raise @error.CompositionError::EmptyPort("SessionStore")
  }
  let tool_routing = build_tool_routing(agg.tools)
  // A wired CatalogSource owns the catalog from the start: the initial
  // snapshot comes from its definitions (version 1), and the composition
  // fails fast if those definitions are invalid. Otherwise the catalog is
  // the static port-derived snapshot.
  let (catalog, initial_catalog_revision) = match catalog_source {
    Some(source) =>
      (
        build_catalog_from_defs(source.tools(), @kernel.CatalogVersion(1)),
        source.revision(),
      )
    None => (build_agent_catalog(tool_routing.providers), 0)
  }
  // One composed hook chain, in interception order: system-prompt
  // projection first, memory retrieval second, extension-registered hooks
  // next, UI rendering last (opt-in, so extensions see events before the
  // renderer).
  let composed_hooks : Array[&@port.Hook] = []
  // 1. System prompt projection (if any contributors are configured).
  if !agg.prompt_contributors.is_empty() {
    let sections : Array[SystemPromptSection] = []
    for pair in agg.prompt_contributors {
      let (mid, contributor) = pair
      sections.push(
        SystemPromptSection::SystemPromptSection(id=mid, contributor~),
      )
    }
    let sys_hook = SystemPromptHook::SystemPromptHook(
      base_prompt="", // base comes from a contributor if desired
      contributors=sections,
    )
    composed_hooks.push(sys_hook as &@port.Hook)
  }

  if !agg.memory.is_empty() {
    let observers = agg.observers
    let mem_hook = MemoryRetrievalHook::MemoryRetrievalHook(
      memories=agg.memory,
      on_failure=fn(reason) {
        emit_secondary_failure(observers, "memory_retrieval", reason)
      },
    )
    composed_hooks.push(mem_hook as &@port.Hook)
  }

  for h in agg.hooks {
    composed_hooks.push(h)
  }

  // UI projection is opt-in (BR-5): the built-in render policy is installed
  // only when the host asks for it via `ui_projection=true`.
  if ui_projection {
    let ui_hook = UiRenderHook::UiRenderHook(ui=agg.ui)
    composed_hooks.push(ui_hook as &@port.Hook)
  }

  let mailbox = @puppetry.ControlMailbox::ControlMailbox()
  let mailbox_ref : &@puppetry.Mailbox = mailbox as &@puppetry.Mailbox
  let builtin_command_port = BuiltinCommandPort(mailbox_ref)
  let command_ports = compose_agent_commands(agg.commands, builtin_command_port)
  let host : &@kernel_exec.HostRuntime = match host_override {
    Some(h) => h
    None => {
      let port_runtime = @runtime.PortRuntime::new(
        model=agg.model,
        tools=tool_routing.tool_map.copy(),
      )
      RuntimeHostShim::new(port_runtime as &@runtime.Runtime)
      as &@kernel_exec.HostRuntime
    }
  }
  let journal = @puppetry.InMemoryJournal::InMemoryJournal()
  let event_subscriber = AgentEventSubscriber(agg.observers)
  let session_store : &@port.SessionStore? = match agg.sessions {
    [first, ..] => Some(first)
    [] => None
  }
  let puppet_config : @puppetry.PuppetConfig = {
    host,
    catalog,
    journal: journal as &@puppetry.RunJournal,
    contributors: [],
    subscribers: [event_subscriber as &@puppetry.EventSubscriber],
    stream_chunks: create_agent_stream_callback(agg.observers),
    hooks: composed_hooks,
    max_iterations: match config.max_tool_rounds {
      // The pump burns 2 iterations per tool round (AwaitingModel +
      // AwaitingTools) plus one terminal iteration. Size the backstop so
      // the kernel budget is always the limit that fires first; with an
      // unbounded budget there is no pump cap either.
      Some(rounds) => Some(2 * rounds + 1)
      None => None
    },
    commands: command_ports,
    session_store,
    mailbox: mailbox_ref,
  }
  let runtime : AgentRuntime = {
    puppet: @puppetry.Puppet(puppet_config),
    observers: agg.observers,
    sessions: agg.sessions,
    lifecycle: agg.lifecycle,
    commands: agg.commands,
    builtin_command_port,
    control: @runtime.RuntimeControl::new(mailbox_ref),
    config,
    catalog_source,
    last_catalog_revision: initial_catalog_revision,
    next_catalog_version: 2,
    next_run_seq: 1,
    shutdown_started: false,
    shutdown_complete: false,
    next_lifecycle_shutdown: agg.lifecycle.length() - 1,
  }
  { runtime, }
}

///|
/// Construct an Agent from an array of extensions.
/// Aggregation order is the array order; collisions fail-fast.
/// At least one extension must contribute a `ModelPort`.
///
/// `ui_projection` (default `false`, BR-5): when `true`, Posoco's built-in
/// render policy (`UiRenderHook` over the aggregated `UiPort`) is appended
/// to the hook chain. Hosts with their own UI policy leave it off and
/// register their own `Hook` instead.
pub fn Agent::Agent(
  exts~ : Array[&@port.Extension],
  config~ : AgentConfig,
  ui_projection? : Bool = false,
) -> Agent raise @error.CompositionError {
  let agg = aggregate_extensions(exts)
  AgentRuntime::compose(agg, config, ui_projection, None, None)
}

///|
/// Advanced constructor (experimental runtime seam): same Agent, same
/// default assembly, but the caller supplies the effect-execution runtime.
/// `runtime` is typically a wrapper around `@runtime.PortRuntime` that
/// overrides only the methods it needs (e.g. `execute_tool` +
/// `cancel_effects` for cancellation propagation). Plain extension authors
/// do not need this — see `docs/RUNTIME.md`.
///
/// `catalog_source` (optional): when supplied, the source owns the tool
/// catalog — Posoco reads it at construction and re-reads it at each prompt
/// boundary whose `revision()` changed, swapping the refreshed snapshot in
/// for subsequent runs while in-flight runs keep their version. Definitions
/// are taken verbatim (owner/policy respected). Without it, the catalog is
/// the static snapshot of the aggregated `ToolProvider` declarations.
pub fn Agent::with_runtime(
  exts~ : Array[&@port.Extension],
  config~ : AgentConfig,
  runtime~ : &@runtime.Runtime,
  ui_projection? : Bool = false,
  catalog_source? : &@runtime.CatalogSource,
) -> Agent raise @error.CompositionError {
  let agg = aggregate_extensions(exts)
  AgentRuntime::compose(
    agg,
    config,
    ui_projection,
    Some(RuntimeHostShim::new(runtime) as &@kernel_exec.HostRuntime),
    catalog_source,
  )
}

///|
async fn AgentRuntime::shutdown(self : AgentRuntime) -> Unit {
  if self.shutdown_complete {
    return
  }
  self.shutdown_started = true
  self.puppet.shutdown() catch {
    error =>
      raise @error.AgentError::Runtime(
        @error.RuntimeError::InvocationFailed(
          "puppet shutdown: " + safe_error_label(error.to_string()),
        ),
      )
  }
  while self.next_lifecycle_shutdown >= 0 {
    // Advance the cursor only after successful cleanup. If an extension
    // shutdown fails, the error remains loud and a retry resumes at the
    // failing extension instead of skipping it or repeating completed ones.
    self.lifecycle[self.next_lifecycle_shutdown].on_shutdown()
    self.next_lifecycle_shutdown = self.next_lifecycle_shutdown - 1
  }
  self.shutdown_complete = true
}

///|
fn agent_error_category(error : @error.AgentError) -> String {
  match error {
    Model(_) => "AgentError::Model"
    Session(_) => "AgentError::Session"
    Runtime(_) => "AgentError::Runtime"
    ToolLoopExceeded(..) => "AgentError::ToolLoopExceeded"
    HookAborted(_) => "AgentError::HookAborted"
  }
}

///|
fn session_error_category(error : @error.SessionError) -> String {
  match error {
    Load(_) => "SessionError::Load"
    Save(_) => "SessionError::Save"
  }
}

///|
fn contextualize_session_error(
  error : @error.SessionError,
  session_id : String,
  operation : String,
) -> @error.AgentError {
  let context = "session_id='\{safe_error_label(session_id)}', operation='\{operation}', category='\{session_error_category(error)}', cause='\{safe_error_label(error.to_string())}'"
  @error.AgentError::Session(
    match error {
      Load(_) => @error.SessionError::Load(context)
      Save(_) => @error.SessionError::Save(context)
    },
  )
}

///|
fn @types.TurnEvent::tool_call_result(
  call : @kernel.ToolCall,
  result : @kernel.ToolOutcome,
) -> @types.TurnEvent {
  ToolCallResult(call~, result~, is_error=result.is_failure())
}

///|
fn agent_snapshot_turn_event(event : @types.TurnEvent) -> @types.TurnEvent {
  match event {
    TurnStarted => TurnStarted
    ToolCallPending(call) => ToolCallPending(agent_snapshot_tool_call(call))
    ToolCallResult(call~, result~, ..) =>
      @types.TurnEvent::tool_call_result(
        agent_snapshot_tool_call(call),
        agent_snapshot_tool_outcome(result),
      )
    ModelResponseReceived(message~, usage~) =>
      ModelResponseReceived(message=agent_snapshot_message(message), usage~)
    SessionRedirect(from~, to~, messages_before~, messages_after~) =>
      SessionRedirect(from~, to~, messages_before~, messages_after~)
    TurnCompleted => TurnCompleted
    TurnFailed(reason) => TurnFailed(reason)
    ToolCallDeferred(call~, reason~) =>
      ToolCallDeferred(call=agent_snapshot_tool_call(call), reason~)
    StreamChunkReceived(chunk~) => StreamChunkReceived(chunk~)
    ConfigWarning(field~, value~, reason~) =>
      ConfigWarning(field~, value~, reason~)
    ConfigChanged(field~, old_value~, new_value~) =>
      ConfigChanged(field~, old_value~, new_value~)
    Custom(source~, label~, data~) =>
      Custom(source~, label~, data=agent_snapshot_json(data))
  }
}

///|
fn AgentRuntime::emit_turn_event(
  self : AgentRuntime,
  event : @types.TurnEvent,
) -> Unit {
  for observer in self.observers {
    observer.on_event(agent_snapshot_turn_event(event))
  }
}

///|
/// Emit a sanitized secondary-failure diagnostic to every observer's
/// session-level channel. Secondary failures never replace the primary
/// outcome; the payload carries only a bounded category label, never the
/// failing component's raw payload. (`Observer::on_event` is non-raising
/// by contract — a violating observer aborts loudly, which the ADR's
/// error-transparency rules intentionally preserve.)
fn emit_secondary_failure(
  observers : Array[&@port.Observer],
  hook_point : String,
  detail : String,
) -> Unit {
  let event : @types.TurnEvent = Custom(
    source="posoco.core",
    label="secondary_failure",
    data=Json::object(
      Map::from_array([
        ("hook_point", Json::string(hook_point)),
        ("error", Json::string(safe_error_label(detail))),
      ]),
    ),
  )
  for observer in observers {
    observer.on_event(agent_snapshot_turn_event(event))
  }
}

///|
fn AgentRuntime::ensure_agent_running(
  self : AgentRuntime,
) -> Unit raise @error.AgentError {
  if self.shutdown_started {
    raise @error.AgentError::Runtime(
      @error.RuntimeError::InvocationFailed("agent is shut down"),
    )
  }
}

///|
/// Cap on follow-up turns drained within a single `run_turn` call, aligned
/// with the control queue bound. Prevents an unbounded follow-up chain from
/// monopolising one call; leftovers stay queued for the next call.
let follow_up_drain_limit : Int = 64

///|
/// Run one agent turn, then drain any follow-ups submitted while it (or a
/// drained follow-up turn) was in flight. Each drained follow-up drives its
/// own full turn on the same session — same boundaries, same observer
/// events — and the last turn's `TurnResult` is returned.
async fn AgentRuntime::run_turn(
  self : AgentRuntime,
  input : @kernel.Message,
  session_id : String,
) -> @types.TurnResult raise @error.AgentError {
  let mut result = self.run_single_turn(input, session_id)
  let mut drained = 0
  while drained < follow_up_drain_limit {
    match self.control.take_follow_up() {
      Some(message) => {
        drained = drained + 1
        result = self.run_single_turn(message, session_id)
      }
      None => break
    }
  }
  result
}

///|
/// Run exactly one turn: delegate to Puppet; emit start/end events for
/// observers.
async fn AgentRuntime::run_single_turn(
  self : AgentRuntime,
  input : @kernel.Message,
  session_id : String,
) -> @types.TurnResult raise @error.AgentError {
  self.ensure_agent_running()
  self.emit_turn_event(TurnStarted)

  let result = self.run_turn_via_puppet(input, session_id) catch {
    primary => {
      let safe_reason = "turn failed: " + agent_error_category(primary)
      self.emit_turn_event(TurnFailed(safe_reason))
      raise primary
    }
  }

  self.emit_turn_event(TurnCompleted)
  result
}

///|
pub async fn Agent::run_turn(
  self : Agent,
  input : @kernel.Message,
  session_id : String,
) -> @types.TurnResult raise @error.AgentError {
  self.runtime.run_turn(input, session_id)
}

///|
pub async fn Agent::shutdown(self : Agent) -> Unit {
  self.runtime.shutdown()
}

///|
/// The Agent's control handle (experimental runtime seam). Advanced hosts
/// use it to abort the active run or to submit follow-up messages that the
/// Agent consumes at turn boundaries. The handle is identity-guarded:
/// submissions against a stale or absent run are rejected at enqueue time.
pub fn Agent::control(self : Agent) -> @runtime.RuntimeControl {
  self.runtime.control
}