Chapter 3: Your First Agent
Learning objectives: Understand the
MoFAAgenttrait, implement it from scratch, and run your agent using the runtime’srun_agentsfunction.
The MoFAAgent Trait
Every agent in MoFA implements the MoFAAgent trait, defined in mofa-kernel. Let’s look at it:
#![allow(unused)]
fn main() {
// crates/mofa-kernel/src/agent/core.rs
#[async_trait]
pub trait MoFAAgent: Send + Sync + 'static {
// Identity
fn id(&self) -> &str;
fn name(&self) -> &str;
fn capabilities(&self) -> &AgentCapabilities;
// Lifecycle
async fn initialize(&mut self, ctx: &AgentContext) -> AgentResult<()>;
async fn execute(&mut self, input: AgentInput, ctx: &AgentContext) -> AgentResult<AgentOutput>;
async fn shutdown(&mut self) -> AgentResult<()>;
// State
fn state(&self) -> AgentState;
}
}
This is the contract every agent must fulfill. Let’s break down each part.
Rust tip:
#[async_trait]Rust traits don’t natively supportasync fnmethods yet. Theasync_traitmacro from theasync-traitcrate works around this by transformingasync fninto methods that returnPin<Box<dyn Future>>. You’ll see this macro on most MoFA traits.
Understanding the Types
AgentInput
What the agent receives:
#![allow(unused)]
fn main() {
pub enum AgentInput {
Text(String), // Simple text input
Texts(Vec<String>), // Multiple text inputs
Json(serde_json::Value), // Structured JSON
Map(HashMap<String, serde_json::Value>), // Key-value pairs
Binary(Vec<u8>), // Binary data
Empty, // No input
}
}
You can create inputs easily:
#![allow(unused)]
fn main() {
let input = AgentInput::text("Hello, agent!");
let input = AgentInput::json(serde_json::json!({"task": "greet", "name": "Alice"}));
}
AgentOutput
What the agent returns:
#![allow(unused)]
fn main() {
pub struct AgentOutput {
pub content: OutputContent,
pub metadata: HashMap<String, serde_json::Value>,
pub tools_used: Vec<ToolUsage>,
pub reasoning_steps: Vec<ReasoningStep>,
pub duration_ms: u64,
pub token_usage: Option<TokenUsage>,
}
}
The simplest way to create one:
#![allow(unused)]
fn main() {
AgentOutput::text("Hello, human!")
}
AgentState
The lifecycle states an agent transitions through:
Created → Initializing → Ready → Running → Executing → Shutdown
↕ ↕
Paused Interrupted
The most important states for now:
#![allow(unused)]
fn main() {
pub enum AgentState {
Created, // Just constructed
Ready, // Initialized and ready to accept input
Running, // Actively processing
Shutdown, // Stopped
// ... and more (Paused, Failed, Error, etc.)
}
}
AgentContext
The execution context passed to initialize and execute:
#![allow(unused)]
fn main() {
pub struct AgentContext {
pub execution_id: String,
pub session_id: Option<String>,
// ... internal fields
}
}
It provides:
- Key-value state:
ctx.set("key", value)/ctx.get::<T>("key") - Event bus:
ctx.emit_event(event)/ctx.subscribe("event_type") - Interrupt handling:
ctx.is_interrupted()/ctx.trigger_interrupt() - Hierarchical contexts:
ctx.child("sub-execution-id")
Build: A GreetingAgent
Let’s implement a simple agent that takes a name and returns a greeting. Create a new Rust project:
cargo new greeting_agent
cd greeting_agent
Edit Cargo.toml:
[package]
name = "greeting_agent"
version = "0.1.0"
edition = "2024"
[dependencies]
mofa-sdk = { path = "../../crates/mofa-sdk" }
async-trait = "0.1"
tokio = { version = "1", features = ["full"] }
anyhow = "1"
serde_json = "1"
Note: We use
path = "../../crates/mofa-sdk"to reference the local workspace. When MoFA is published to crates.io, you’d useversion = "0.1"instead.
Now write src/main.rs:
use async_trait::async_trait;
use mofa_sdk::kernel::{
AgentCapabilities, AgentCapabilitiesBuilder, AgentContext, AgentInput,
AgentOutput, AgentResult, AgentState, MoFAAgent,
};
use mofa_sdk::runtime::run_agents;
// --- Define our agent ---
struct GreetingAgent {
id: String,
name: String,
caps: AgentCapabilities,
state: AgentState,
}
impl GreetingAgent {
fn new() -> Self {
Self {
id: "greeting-001".to_string(),
name: "GreetingAgent".to_string(),
caps: AgentCapabilitiesBuilder::new().build(),
state: AgentState::Created,
}
}
}
#[async_trait]
impl MoFAAgent for GreetingAgent {
fn id(&self) -> &str {
&self.id
}
fn name(&self) -> &str {
&self.name
}
fn capabilities(&self) -> &AgentCapabilities {
&self.caps
}
async fn initialize(&mut self, _ctx: &AgentContext) -> AgentResult<()> {
println!("[GreetingAgent] Initializing...");
self.state = AgentState::Ready;
Ok(())
}
async fn execute(
&mut self,
input: AgentInput,
_ctx: &AgentContext,
) -> AgentResult<AgentOutput> {
// Extract the name from input
let name = match &input {
AgentInput::Text(text) => text.clone(),
_ => "World".to_string(),
};
let greeting = format!("Hello, {}! Welcome to MoFA.", name);
Ok(AgentOutput::text(greeting))
}
async fn shutdown(&mut self) -> AgentResult<()> {
println!("[GreetingAgent] Shutting down...");
self.state = AgentState::Shutdown;
Ok(())
}
fn state(&self) -> AgentState {
self.state.clone()
}
}
// --- Run it ---
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let agent = GreetingAgent::new();
// run_agents handles the full lifecycle:
// initialize → execute (for each input) → shutdown
let outputs = run_agents(
agent,
vec![
AgentInput::text("Alice"),
AgentInput::text("Bob"),
AgentInput::text("GSoC Student"),
],
)
.await?;
for output in &outputs {
println!("Output: {}", output.to_text());
}
Ok(())
}
Run it:
cargo run
Expected output:
[GreetingAgent] Initializing...
Output: Hello, Alice! Welcome to MoFA.
Output: Hello, Bob! Welcome to MoFA.
Output: Hello, GSoC Student! Welcome to MoFA.
[GreetingAgent] Shutting down...
What Just Happened?
Let’s trace the execution:
GreetingAgent::new()— Creates an agent inAgentState::Createdrun_agents(agent, inputs)— The runtime takes over:- Calls
agent.initialize(&ctx)— agent transitions toReady - For each input, calls
agent.execute(input, &ctx)— agent processes input - Calls
agent.shutdown()— agent transitions toShutdown
- Calls
outputs— We get back aVec<AgentOutput>, one per input
Architecture note: Notice that our
GreetingAgentonly uses types frommofa-kernel(the traits and types) andmofa-runtime(therun_agentsfunction). We didn’t need any foundation code because our agent doesn’t use an LLM, tools, or persistence. This is the microkernel at work — minimal core, optional everything.
The run_agents function lives in mofa-runtime (crates/mofa-runtime/src/runner.rs). It’s the simplest way to run an agent. For more control, you can use AgentRunner directly:
#![allow(unused)]
fn main() {
use mofa_sdk::runtime::{AgentRunner, AgentRunnerBuilder};
let runner = AgentRunnerBuilder::new()
.with_agent(GreetingAgent::new())
.build();
// Run with lifecycle management
let result = runner.run(AgentInput::text("Alice")).await?;
}
Using AgentContext for State
The AgentContext is passed to both initialize and execute. You can use it to store state between executions:
#![allow(unused)]
fn main() {
async fn initialize(&mut self, ctx: &AgentContext) -> AgentResult<()> {
// Store initial state
ctx.set("call_count", 0u32).await;
self.state = AgentState::Ready;
Ok(())
}
async fn execute(
&mut self,
input: AgentInput,
ctx: &AgentContext,
) -> AgentResult<AgentOutput> {
// Read and update state
let count: u32 = ctx.get("call_count").await.unwrap_or(0);
ctx.set("call_count", count + 1).await;
let name = input.to_text();
let greeting = format!("Hello, {}! You are caller #{}.", name, count + 1);
Ok(AgentOutput::text(greeting))
}
}
Rust tip:
ArcandRwLockInsideAgentContext, the state is stored inArc<RwLock<HashMap<...>>>.Arc(Atomic Reference Counting) lets multiple parts of the code share ownership of the data.RwLockallows multiple readers OR one writer at a time. This is how Rust handles shared mutable state safely in async code — no data races possible.
Key Takeaways
- Every agent implements
MoFAAgentwith 7 required methods:id,name,capabilities,initialize,execute,shutdown,state AgentInputis an enum — agents can receive text, JSON, binary, or nothingAgentOutput::text("...")is the simplest way to return a responserun_agents()handles the full lifecycle: initialize → execute → shutdownAgentContextprovides key-value state, events, and interrupt handling- Your agent code uses only kernel traits and runtime functions — no LLM needed
Next: Chapter 4: LLM-Powered Agent — Connect your agent to a real LLM.
English | 简体中文