///|
/// Event type that occurs during agent conversation lifecycle.
///
/// A typical conversation lifecycle looks like this:
///
/// ```moonbit no-check
/// // add system and user messages
/// UserMessage (system)
/// UserMessage (user)
/// // calling agent.start()
/// PreConversation
/// while true {
/// // poll external events
/// ExternalEventReceived (if any)
/// // count tokens before request
/// TokenCounted
/// // prune context if necessary
/// ContextPruned
/// // receive assistant response
/// AssistantMessage
/// // executing tool call
/// PreToolCall
/// PostToolCall
/// UserMessage (tool)
/// // continue to iterate if there are more messages
/// }
/// // conversation ended
/// PostConversation
/// ```
pub struct Event {
id : @uuid.Uuid
created : @clock.Timestamp
desc : EventDesc
} derive(Eq)
///|
/// Creates a new `Event` with the specified parameters.
///
/// Parameters:
///
/// * `id` : The unique identifier for the event.
/// * `created` : The timestamp when the event was created. Defaults to the
/// current time if not provided.
/// * `desc` : The event description that specifies the type and content of the
/// event.
///
/// Returns a new `Event` instance.
pub fn Event::new(
id~ : @uuid.Uuid,
created? : @clock.Timestamp = @clock.epoch.now(),
desc : EventDesc,
) -> Event {
{ id, created, desc }
}
///|
pub(all) enum EventDesc {
/// Event triggered when a model is loaded.
ModelLoaded(name~ : String)
/// Event triggered before a conversation starts.
PreConversation
/// Event triggered after a conversation ends.
PostConversation
/// Event triggered when the system prompt is being set.
SystemPromptSet(String?)
/// Event triggered when a message is unqueued from the pending queue.
MessageUnqueued(id~ : @uuid.Uuid)
/// Event triggered when a message is queued to be sent to the model.
/// The message ID can be obtained via the return value of `queue_message`
/// function.
MessageQueued(id~ : @uuid.Uuid)
/// Event triggered when a tool is added to the agent. This message will only
/// be triggered once per agent instance.
ToolAdded(@tool.ToolDesc)
/// Event triggered before a tool is called.
PreToolCall(@ai.ToolCall)
/// Event triggered after a tool call is completed.
///
/// If you are interested in the result of the tool call, then this is the
/// event you might want to listen for.
///
/// The `result` field can be used to determine whether the tool call was
/// successful or not. `rendered` field is the string representation of the
/// tool call result, which can be used for both human and LLM consumption.
PostToolCall(@ai.ToolCall, result~ : Result[Json, Json], rendered~ : String)
/// Event triggered when tokens are counted for a message or tool call.
/// Tokens are counted in following scenarios:
///
/// 1. Before a request is being sent, tokens are counted for all messages
/// in the conversation history to decide if to perform context pruning.
/// 2. The context pruning algorithm may count token for multiple times to
/// determine when to stop.
///
/// FIXME: Currently this event is triggered multiple times in a row.
TokenCounted(Int)
/// Event triggered when context pruning is performed.
///
/// FIXME: Currently this event is triggered even if the context is with-in
/// the limit and no pruning is performed.
ContextPruned(origin_token_count~ : Int, pruned_token_count~ : Int)
/// Event triggered when a assistant message is received from the model.
AssistantMessageDelta(String)
/// Event triggered when a assistant message is received from the model.
AssistantMessage(
usage~ : @ai.Usage?,
tool_calls~ : Array[@ai.ToolCall],
String
)
/// User sends an immediate message (interrupting current flow)
UserMessage(String)
/// Cancelled
Cancelled
/// Failed. We cannot use `Error` here because it is impossible to deserialize
/// `Error` from JSON.
Failed(Json)
/// Event triggered when a PostToolCall event is pruned from context.
/// The `id` field references the pruned PostToolCall event's id.
Pruned(id~ : @uuid.Uuid)
} derive(Eq)
///|
pub impl ToJson for EventDesc with fn to_json(self : EventDesc) -> Json {
match self {
ModelLoaded(name~) => { "msg": "ModelLoaded", "name": name.to_json() }
TokenCounted(token_count) =>
{ "msg": "TokenCounted", "token_count": token_count }
ContextPruned(origin_token_count~, pruned_token_count~) =>
{
"msg": "ContextPruned",
"origin_token_count": origin_token_count,
"pruned_token_count": pruned_token_count,
}
PreToolCall(tool_call) => {
let tool_call = tool_call.to_openai()
match tool_call.function.arguments {
None =>
{
"msg": "PreToolCall",
"tool_call": tool_call,
"name": tool_call.function.name,
}
Some(arguments) =>
try @json.parse(arguments) catch {
error =>
{
"msg": "PreToolCall",
"tool_call": tool_call,
"name": tool_call.function.name,
"args": tool_call.function.arguments,
"error": error,
}
} noraise {
args =>
{
"msg": "PreToolCall",
"tool_call": tool_call,
"name": tool_call.function.name,
"args": args,
}
}
}
}
PostToolCall(tool_call, result~, rendered~) => {
let tool_call = tool_call.to_openai()
match result {
Ok(output) =>
{
"msg": "PostToolCall",
"tool_call": tool_call,
"name": tool_call.function.name,
"result": output,
"text": rendered,
}
Err(error) =>
{
"msg": "PostToolCall",
"tool_call": tool_call,
"name": tool_call.function.name,
"error": error,
"text": rendered,
}
}
}
PreConversation => { "msg": "PreConversation" }
PostConversation => { "msg": "PostConversation" }
SystemPromptSet(Some(prompt)) =>
{ "msg": "SystemPromptSet", "prompt": prompt }
SystemPromptSet(None) => { "msg": "SystemPromptSet" }
MessageQueued(id~) => { "msg": "MessageQueued", "message": { "id": id } }
MessageUnqueued(id~) =>
{ "msg": "MessageUnqueued", "message": { "id": id } }
ToolAdded(tool_desc) =>
{
"msg": "ToolAdded",
"tool": {
"name": tool_desc.name,
"description": tool_desc.description,
"schema": tool_desc.schema,
},
}
AssistantMessageDelta(content) =>
{ "msg": "AssistantMessageDelta", "content": content }
AssistantMessage(usage~, tool_calls~, content) => {
let json : Map[String, Json] = {
"msg": "AssistantMessage",
"tool_calls": tool_calls.map(fn(tc) { tc.to_openai() }),
"content": content,
}
if usage is Some(usage) {
json["usage"] = usage.to_openai().to_json()
}
Json::object(json)
}
Cancelled => { "msg": "Cancelled" }
UserMessage(content) => { "msg": "UserMessage", "content": content }
Failed(error) => { "msg": "Failed", "error": error }
Pruned(id~) => { "msg": "Pruned", "id": id.to_json() }
}
}
///|
pub impl @json.FromJson for EventDesc with fn from_json(
json : Json,
json_path : @json.JsonPath,
) -> EventDesc raise @json.JsonDecodeError {
guard json is Object({ "msg": String(msg), .. } as json_object) else {
raise JsonDecodeError((json_path, "Missing 'msg' field in OutgoingEvent"))
}
match msg {
"ModelLoaded" => {
guard json_object.get("name") is Some(name_json) else {
raise JsonDecodeError(
(json_path, "ModelLoaded event missing 'name' field"),
)
}
let name : String = @json.from_json(
name_json,
path=json_path.add_key("name"),
)
ModelLoaded(name~)
}
"TokenCounted" => {
guard json_object.get("token_count") is Some(token_count_json) else {
raise JsonDecodeError(
(json_path, "TokenCounted event missing 'token_count' field"),
)
}
let token_count : Int = @json.from_json(
token_count_json,
path=json_path.add_key("token_count"),
)
TokenCounted(token_count)
}
"ContextPruned" => {
guard json_object.get("origin_token_count")
is Some(origin_token_count_json) else {
raise JsonDecodeError(
(json_path, "ContextPruned event missing 'origin_token_count' field"),
)
}
let origin_token_count : Int = @json.from_json(
origin_token_count_json,
path=json_path.add_key("origin_token_count"),
)
guard json_object.get("pruned_token_count")
is Some(pruned_token_count_json) else {
raise JsonDecodeError(
(json_path, "ContextPruned event missing 'pruned_token_count' field"),
)
}
let pruned_token_count : Int = @json.from_json(
pruned_token_count_json,
path=json_path.add_key("pruned_token_count"),
)
ContextPruned(origin_token_count~, pruned_token_count~)
}
"PreToolCall" => {
guard json_object.get("tool_call") is Some(tool_call_json) else {
raise JsonDecodeError(
(json_path, "PreToolCall event missing 'tool_call' field"),
)
}
let tool_call : @openai.ChatCompletionMessageToolCall = @json.from_json(
tool_call_json,
path=json_path.add_key("tool_call"),
)
PreToolCall(@ai.ToolCall::from_openai_tool_call(tool_call))
}
"PostToolCall" => {
guard json_object.get("tool_call") is Some(tool_call_json) else {
raise JsonDecodeError(
(json_path, "PostToolCall event missing 'tool_call' field"),
)
}
let tool_call : @openai.ChatCompletionMessageToolCall = @json.from_json(
tool_call_json,
path=json_path.add_key("tool_call"),
)
let result : Result[Json, Json] = match
(json_object.get("result"), json_object.get("error")) {
(Some(result_json), _) => Ok(result_json)
(None, Some(error_json)) => Err(error_json)
(None, None) =>
raise JsonDecodeError(
(
json_path, "PostToolCall event missing both 'result' and 'error' fields",
),
)
}
guard json_object.get("text") is Some(rendered_json) else {
raise JsonDecodeError(
(json_path, "PostToolCall event missing 'text' field"),
)
}
let rendered : String = @json.from_json(
rendered_json,
path=json_path.add_key("text"),
)
PostToolCall(
@ai.ToolCall::from_openai_tool_call(tool_call),
result~,
rendered~,
)
}
"PreConversation" => PreConversation
"PostConversation" => PostConversation
"SystemPromptSet" => {
let prompt : String? = match json_object.get("prompt") {
None | Some(Null) => None
Some(prompt_json) => {
let prompt : String = @json.from_json(
prompt_json,
path=json_path.add_key("prompt"),
)
Some(prompt)
}
}
SystemPromptSet(prompt)
}
"MessageQueued" => {
guard json_object.get("message") is Some(message_wrapper_json) else {
raise JsonDecodeError(
(json_path, "MessageQueued event missing 'message' field"),
)
}
guard message_wrapper_json is Object({ "id": id_json, .. }) else {
raise JsonDecodeError(
(json_path, "Invalid 'message' field in MessageQueued event"),
)
}
let id : @uuid.Uuid = @json.from_json(
id_json,
path=json_path.add_key("message").add_key("id"),
)
MessageQueued(id~)
}
"MessageUnqueued" => {
guard json_object.get("message") is Some(message_wrapper_json) else {
raise JsonDecodeError(
(json_path, "MessageUnqueued event missing 'message' field"),
)
}
guard message_wrapper_json is Object({ "id": id_json, .. }) else {
raise JsonDecodeError(
(json_path, "Invalid 'message' field in MessageUnqueued event"),
)
}
let id : @uuid.Uuid = @json.from_json(
id_json,
path=json_path.add_key("message").add_key("id"),
)
MessageUnqueued(id~)
}
"ToolAdded" => {
guard json_object.get("tool") is Some(tool_json) else {
raise JsonDecodeError(
(json_path, "ToolAdded event missing 'tool' field"),
)
}
guard tool_json
is Object(
{
"name": name_json,
"description": description_json,
"schema": schema_json,
..
}
) else {
raise JsonDecodeError(
(json_path, "Invalid 'tool' field in ToolAdded event"),
)
}
let name : String = @json.from_json(
name_json,
path=json_path.add_key("tool").add_key("name"),
)
let description : String = @json.from_json(
description_json,
path=json_path.add_key("tool").add_key("description"),
)
let schema : Json = schema_json
ToolAdded(@tool.tool_desc(description~, name~, schema~))
}
"AssistantMessageDelta" => {
let content : String = match json_object.get("content") {
None =>
raise JsonDecodeError(
(json_path, "AssistantMessageDelta event missing 'content' field"),
)
Some(content_json) =>
@json.from_json(content_json, path=json_path.add_key("content"))
}
AssistantMessageDelta(content)
}
"AssistantMessage" => {
let usage : @ai.Usage? = match json_object.get("usage") {
None | Some(Null) => None
Some(usage_json) =>
Some(
@ai.Usage::from_openai(
@json.from_json(usage_json, path=json_path.add_key("usage")),
),
)
}
let tool_calls : Array[@ai.ToolCall] = match
json_object.get("tool_calls") {
None => []
Some(tool_calls_json) => {
let tcs : Array[@openai.ChatCompletionMessageToolCall] = @json.from_json(
tool_calls_json,
path=json_path.add_key("tool_calls"),
)
tcs.map(@ai.ToolCall::from_openai_tool_call)
}
}
let content : String = match json_object.get("content") {
None =>
raise JsonDecodeError(
(json_path, "AssistantMessage event missing 'content' field"),
)
Some(content_json) =>
@json.from_json(content_json, path=json_path.add_key("content"))
}
AssistantMessage(usage~, tool_calls~, content)
}
"Cancelled" => Cancelled
"UserMessage" => {
guard json_object.get("content") is Some(content_json) else {
raise JsonDecodeError(
(json_path, "Missing 'content' field in UserMessage event"),
)
}
let content : String = @json.from_json(
content_json,
path=json_path.add_key("content"),
)
UserMessage(content)
}
"Failed" => {
guard json_object.get("error") is Some(error_json) else {
raise JsonDecodeError(
(json_path, "Missing 'error' field in Failed event"),
)
}
Failed(error_json)
}
"Pruned" => {
guard json_object.get("id") is Some(id_json) else {
raise JsonDecodeError((json_path, "Pruned event missing 'id' field"))
}
let id : @uuid.Uuid = @json.from_json(
id_json,
path=json_path.add_key("id"),
)
Pruned(id~)
}
_ =>
raise JsonDecodeError(
(json_path, "Unknown 'msg' value in EventDesc: \{msg}"),
)
}
}
///|
/// Serializes an `Event` to JSON.
///
/// Note: this implementation behaves identically to the derived version.
/// We manually implement it here to ensure the stability of the JSON format.
pub impl ToJson for Event with fn to_json(self : Event) -> Json {
{ "id": self.id.to_json(), "created": self.created, "desc": self.desc }
}
///|
/// Deserializes an `Event` from JSON.
///
/// Note: this implementation behaves identically to the derived version.
/// We manually implement it here to ensure the stability of the JSON format.
pub impl @json.FromJson for Event with fn from_json(
json : Json,
json_path : @json.JsonPath,
) -> Event raise @json.JsonDecodeError {
let object = @jsonx.as_object(json, path=json_path)
let id : @uuid.Uuid = object.required("id", path=json_path)
let created : @clock.Timestamp = object
.optional("created", path=json_path)
.unwrap_or_else(() => @clock.Timestamp::from_ms(0))
let desc : EventDesc = object.required("desc", path=json_path)
{ id, created, desc }
}
///|
/// External events that can be sent to the agent from outside sources.
/// These events are collected via polling and can influence the conversation.
pub fn Event::is_incoming(self : Event) -> Bool {
match self.desc {
Cancelled | UserMessage(_) => true
_ => false
}
}
///|
/// Indicates whether the event signifies the end of a conversation.
pub fn Event::is_stopping(self : Event) -> Bool {
match self.desc {
PostConversation | Failed(_) => true
_ => false
}
}
///|
/// Indicates whether the event signifies the start of a conversation.
pub fn Event::is_starting(self : Event) -> Bool {
match self.desc {
PreConversation => true
_ => false
}
}
///|
/// Indicates whether the event represents a cancellation.
pub fn Event::is_cancellation(self : Event) -> Bool {
match self.desc {
Cancelled => true
_ => false
}
}