///|
pub(all) struct AgentConfig {
max_tool_rounds : Int
temperature : Double?
max_output_tokens : Int?
tool_choice : ToolChoice?
reasoning_effort : ReasoningEffort?
model_context_window : Int?
}
///|
pub fn AgentConfig::to_chat_options(self : AgentConfig) -> ChatOptions {
{
temperature: self.temperature,
max_output_tokens: self.max_output_tokens,
tool_choice: self.tool_choice,
reasoning_effort: self.reasoning_effort,
}
}
///|
/// Internal routing table: tool name → owning provider. Built during Agent::new.
struct ToolRouting {
tool_map : Map[String, &ToolProvider]
providers : Array[&ToolProvider]
collisions : Array[String]
}
///|
fn build_tool_routing(tools : Array[&ToolProvider]) -> ToolRouting {
let tool_map : Map[String, &ToolProvider] = Map::from_array([])
let collisions : Array[String] = []
for provider in tools {
let tool_defs = provider.list_tools()
for tool in tool_defs {
if tool_map.contains(tool.name) {
collisions.push(
"Tool '\{tool.name}' registered by multiple providers; last one wins",
)
}
tool_map[tool.name] = provider
}
}
{ tool_map, providers: tools, collisions }
}
///|
/// Agent — array-based composition: tools routed by name, hooks chained,
/// observers fan-out, compressors first-wins, sessions write-all/read-first.
pub(all) struct Agent {
model_port : &ModelPort
tool_routing : ToolRouting
pipeline_hook : &PipelineHook?
compressors : Array[&Compressor]
observers : Array[&Observer]
memories : Array[&MemoryPort]
sessions : Array[&SessionStore]
lifecycle : &Lifecycle?
config : AgentConfig
}
///|
/// Construct an Agent with array-based composition.
///
/// - `tools`: routed by tool name (last-wins on collision)
/// - `hooks`: chained in order (first runs first)
/// - `memory`: fan-out store/search
/// - `compressors`: first non-None wins
/// - `observers`: all receive every event
/// - `sessions`: write-all, read-first
pub fn Agent::new(
model_port : &ModelPort,
tools~ : Array[&ToolProvider],
hooks~ : Array[&PipelineHook],
memory~ : Array[&MemoryPort],
compressors~ : Array[&Compressor],
observers~ : Array[&Observer],
sessions~ : Array[&SessionStore],
lifecycle~ : &Lifecycle?,
config~ : AgentConfig,
) -> Agent {
let tool_routing = build_tool_routing(tools)
let pipeline_hook : &PipelineHook? = match hooks.length() {
0 => None
1 => Some(hooks[0])
_ => Some(HookChain::{ hooks, })
}
{
model_port,
tool_routing,
pipeline_hook,
compressors,
observers,
memories: memory,
sessions,
lifecycle,
config,
}
}
///|
pub fn Agent::tool_collisions(self : Agent) -> Array[String] {
self.tool_routing.collisions.copy()
}
///|
pub async fn Agent::shutdown(self : Agent) -> Unit {
match self.lifecycle {
Some(lc) => lc.on_shutdown() catch { _ => () }
None => ()
}
}
///|
async fn call_hook(
hook : &PipelineHook,
stage : Stage,
) -> Result[Stage, HookError] raise AgentError {
hook.on_stage(stage) catch {
e => raise HookAborted(e.to_string())
}
}
///|
async fn ignore_hook(hook : &PipelineHook, stage : Stage) -> Unit {
ignore(hook.on_stage(stage)) catch {
_ => ()
}
}
///|
/// Run one full agent turn. Tool calls execute in parallel via @async.all.
pub async fn Agent::run_turn(
self : Agent,
input : Message,
session_id : String,
) -> TurnResult raise AgentError {
let result = self.run_turn_inner(input, session_id) catch {
e => {
// Finally: ensure TurnError fires on any error path before propagating.
match self.pipeline_hook {
Some(hook) => ignore_hook(hook, TurnError(e)) catch { _ => () }
None => ()
}
raise e
}
}
result
}
///|
async fn Agent::run_turn_inner(
self : Agent,
input : Message,
session_id : String,
) -> TurnResult raise AgentError {
match self.pipeline_hook {
Some(hook) =>
match call_hook(hook, BeforeTurn) {
Ok(_) => ()
Err(e) => raise HookAborted(e.to_string())
}
None => ()
}
for obs in self.observers {
obs.on_event(TurnStarted)
}
let session = match self.sessions {
[first, ..] =>
match first.load(session_id) {
Ok(s) => s
Err(e) => raise Session(e)
}
[] => raise Session(Load("no session store configured"))
}
let mut current_session_id = session_id
let mut messages = session.messages
messages.push(input)
let tools : Array[ToolDef] = []
for provider in self.tool_routing.providers {
let provider_tools = provider.list_tools()
for t in provider_tools {
tools.push(t)
}
}
let mut tool_round = 0
let all_results : Array[ToolResult] = []
let mut resolution_required = false
let initial_msg = {
role: Assistant,
content: [],
tool_calls: [],
tool_call_id: None,
name: None,
reasoning_content: None,
}
let mut response = {
message: initial_msg,
tool_calls: [],
finish_reason: Stop,
usage: None,
reasoning_content: None,
}
while true {
let ctx = {
session_id: current_session_id,
model_context_window: self.config.model_context_window,
}
let mut compress_done = false
for comp in self.compressors {
if compress_done {
break
}
let action = comp.compress(messages, ctx)
match action {
NewThread(
messages=msgs,
new_session_id=new_sid,
messages_before=before,
messages_after=after
) => {
let new_session = {
messages: msgs,
metadata: Map::from_array([
("parent_session_id", Json::string(current_session_id)),
]),
}
for store in self.sessions {
match store.save(new_sid, new_session) {
Ok(_) => ()
Err(e) => raise Session(e)
}
}
for obs in self.observers {
obs.on_event(
SessionRedirect(
from=current_session_id,
to=new_sid,
messages_before=before,
messages_after=after,
),
)
}
current_session_id = new_sid
messages = msgs
compress_done = true
}
Replace(msgs) => {
messages = msgs
compress_done = true
}
None => ()
}
}
match self.pipeline_hook {
Some(hook) =>
match call_hook(hook, BeforeModel(messages)) {
Ok(BeforeModel(msgs)) => messages = msgs
Ok(_) => ()
Err(e) => raise HookAborted(e.to_string())
}
None => ()
}
response = self.model_port.chat_streaming(
messages,
tools,
self.config.to_chat_options(),
fn(chunk : StreamChunk) {
for obs in self.observers {
obs.on_event(StreamChunkReceived(chunk~))
}
},
) catch {
e => {
let reason = e.to_string()
for obs in self.observers {
obs.on_event(TurnFailed(reason))
}
match self.pipeline_hook {
Some(hook) => ignore_hook(hook, ModelError(reason)) catch { _ => () }
None => ()
}
raise Model(reason)
}
}
for obs in self.observers {
obs.on_event(
ModelResponseReceived(
message=response.message,
usage=response.usage,
reasoning_content=response.reasoning_content,
),
)
}
match self.pipeline_hook {
Some(hook) =>
match call_hook(hook, AfterModel(response)) {
Ok(_) => ()
Err(e) => raise HookAborted(e.to_string())
}
None => ()
}
messages.push(response.message)
if response.tool_calls.is_empty() {
break
}
tool_round = tool_round + 1
if tool_round > self.config.max_tool_rounds {
for obs in self.observers {
obs.on_event(TurnFailed("tool loop exceeded"))
}
raise ToolLoopExceeded
}
let approved_calls : Array[ToolCall] = []
for tool_call in response.tool_calls {
for obs in self.observers {
obs.on_event(ToolCallPending(tool_call))
}
let mut effective_call = tool_call
let mut skip_tool = false
match self.pipeline_hook {
Some(hook) =>
match call_hook(hook, BeforeTool(effective_call)) {
Ok(BeforeTool(tc)) => effective_call = tc
Ok(_) => ()
Err(Deferred(reason~)) => {
for obs in self.observers {
obs.on_event(ToolCallDeferred(call=effective_call, reason~))
}
let rejection = ToolResult::{
content: "Tool rejected (needs approval): " + reason,
structured: None,
is_error: true,
}
all_results.push(rejection)
messages.push(
build_tool_result_message(effective_call, rejection),
)
skip_tool = true
resolution_required = true
}
Err(Aborted(msg)) => raise HookAborted(msg)
}
None => ()
}
if skip_tool == false {
approved_calls.push(effective_call)
}
}
let mut exec_results : Array[Result[ToolResult, RuntimeError]] = []
if !approved_calls.is_empty() {
exec_results = @async.all(
approved_calls.map(fn(call) {
async fn() -> Result[ToolResult, RuntimeError] {
match self.tool_routing.tool_map.get(call.name) {
Some(provider) =>
Ok(provider.execute(call.name, call)) catch {
e => Err(e)
}
None =>
Err(
RuntimeError::UnknownTool(
"No provider for tool '\{call.name}'",
),
)
}
}
}),
) catch {
e => raise Model(e.to_string())
}
}
for i = 0; i < approved_calls.length(); i = i + 1 {
let call = approved_calls[i]
match exec_results[i] {
Ok(result) => {
for obs in self.observers {
obs.on_event(ToolCallResult(call~, result~, is_error=false))
}
match self.pipeline_hook {
Some(hook) =>
ignore_hook(hook, AfterTool(call, result)) catch {
_ => ()
}
None => ()
}
all_results.push(result)
messages.push(build_tool_result_message(call, result))
}
Err(e) => {
let error_result = {
content: e.to_string(),
structured: None,
is_error: true,
}
for obs in self.observers {
obs.on_event(
ToolCallResult(call~, result=error_result, is_error=true),
)
}
match self.pipeline_hook {
Some(hook) =>
ignore_hook(hook, ToolError(call, e)) catch {
_ => ()
}
None => ()
}
all_results.push(error_result)
messages.push(build_error_message(call, e))
}
}
}
if resolution_required {
break
}
}
let final_session = { messages, metadata: Map::from_array([]) }
for store in self.sessions {
match store.save(current_session_id, final_session) {
Ok(_) => ()
Err(e) => raise Session(e)
}
}
for obs in self.observers {
obs.on_event(TurnCompleted)
}
let result = {
message: response.message,
tool_results: all_results,
final_session_id: current_session_id,
}
match self.pipeline_hook {
Some(hook) => ignore_hook(hook, AfterTurn(result)) catch { _ => () }
None => ()
}
result
}