Skip to content

Agent Lifecycle

Every task an agent processes flows through a deterministic 10-phase lifecycle. This is the core of the ExecutionEngine — and every phase is wired to its corresponding service when enabled.

┌──────────┐
│ BOOTSTRAP│ ← Load memory context, build system prompt
└────┬─────┘
┌────▼─────┐
│ GUARDRAIL│ ← GuardrailService.check() — blocks unsafe input
└────┬─────┘
┌────▼──────┐
│ COST_ROUTE│ ← CostService.routeToModel() — select optimal tier
└────┬──────┘
┌────▼───────────┐
│ STRATEGY_SELECT│ ← Choose reasoning strategy (or direct LLM)
└────┬───────────┘
┌────▼──┐ ┌─────┐ ┌────────┐
│ THINK │───►│ ACT │───►│OBSERVE │──┐
└───────┘ └─────┘ └────────┘ │
▲ │
└──────────────────────────────┘ (loop until done)
┌────▼───┐
│ VERIFY │ ← VerificationService.verify() — fact-check output
└────┬───┘
┌────▼────────┐
│ MEMORY_FLUSH│ ← MemoryService.flush() + snapshot()
└────┬────────┘
┌────▼──────┐
│ COST_TRACK│ ← CostService.recordCost() — log spend
└────┬──────┘
┌────▼────┐
│ AUDIT │ ← ObservabilityService.info() — audit trail
└────┬────┘
┌────▼─────┐
│ COMPLETE │ ← Build TaskResult with output + metadata
└──────────┘

Loads memory context for the agent:

  • Retrieves semantic entries from the memory database
  • Loads the last session snapshot for continuity
  • Generates a markdown projection of relevant knowledge
  • Injects context into the system prompt

Always runs. If memory is disabled, produces an empty context string.

Calls GuardrailService.check(inputText) on the user’s input:

  • Injection detection, PII scanning, toxicity filtering, contract validation
  • If result.passed is false, throws GuardrailViolationError and stops execution
  • The LLM never sees unsafe input

Requires: .withGuardrails()

Calls CostService.routeToModel(task) to analyze task complexity:

  • Simple tasks route to cheaper models (Haiku)
  • Complex tasks route to more capable models (Opus)
  • Selection stored in context for the Think phase

Requires: .withCostTracking()

Chooses how the agent will reason:

  • If .withReasoning() is enabled, uses the configured strategy (ReAct, Reflexion, etc.)
  • Otherwise defaults to a direct LLM loop with tool calling support

The core reasoning loop, which runs differently based on strategy:

With Reasoning (ReAct example):

  • Think: LLM generates thoughts and actions
  • Act: Actions parsed, tools executed via ToolService
  • Observe: Real tool results fed back as observations
  • Loop until FINAL ANSWER: or max iterations

Without Reasoning (Direct LLM):

  • Think: LLM called with messages + tool definitions
  • Act: If stopReason: "tool_use", tools executed
  • Observe: Tool results appended to message history
  • Loop until LLM returns without requesting tools

Token tracking: After each LLM call, response.usage.totalTokens is accumulated in the execution context.

Context window management: Before each LLM call, messages are truncated via ContextWindowManager.truncate() to stay within token limits.

Memory integration: During the Observe phase, tool results are logged as episodic memories via MemoryService.logEpisode().

Calls VerificationService.verify(response, input):

  • Runs semantic entropy, fact decomposition, self-consistency, and NLI checks
  • Stores verificationScore and riskLevel in context metadata
  • Score and risk available via lifecycle hooks

Requires: .withVerification()

Persists the session:

  • Calls MemoryService.snapshot() to save session state
  • Calls MemoryService.flush() to generate the memory.md projection
  • Stores messages, key decisions, and cost data for future context

Calls CostService.recordCost() with accumulated token/cost data:

  • Records model tier, token counts, latency, and estimated cost
  • Updates budget tracking (per-session, daily, monthly)

Requires: .withCostTracking()

Logs an audit trail entry via ObservabilityService.info():

  • Task summary with ID, agent, iterations, tokens used
  • Cost, strategy, duration, and completion status
  • Full audit trail for compliance and debugging

Requires: .withObservability() or .withAudit()

Builds the final TaskResult:

  • output: The agent’s response text
  • success: Whether the task completed without errors
  • metadata: Duration, cost, tokens used, strategy, step count

When .withEventBus() (or any feature that wires an EventBus) is active, every meaningful lifecycle moment emits a typed event. agent.subscribe() is overloaded — pass a tag to get the event payload automatically narrowed to that type:

// Tag-filtered: event payload is narrowed — no _tag check, no cast
const unsub = await agent.subscribe("AgentCompleted", (event) => {
console.log(event.totalTokens, event.durationMs); // fully typed
});
// Catch-all: receives the full AgentEvent union
const unsub2 = await agent.subscribe((event) => {
if (event._tag === "ToolCallStarted") console.log(event.toolName);
});

Complete event stream for a typical run:

AgentStarted { taskId, agentId, provider, model, timestamp }
ExecutionPhaseEntered { taskId, phase }
ExecutionHookFired { taskId, phase, timing: "before"|"after" }
MemoryBootstrapped { agentId, tier }
ExecutionPhaseCompleted { taskId, phase, durationMs }
LLMRequestStarted { taskId, requestId, model, provider, contextSize }
LLMRequestCompleted { taskId, requestId, tokensUsed, durationMs } ← same requestId
ReasoningStepCompleted { taskId, strategy, step, thought|action|observation }
ToolCallStarted { taskId, toolName, callId }
ToolCallCompleted { taskId, toolName, callId, success, durationMs }
FinalAnswerProduced { taskId, strategy, answer, iteration, totalTokens }
GuardrailViolationDetected{ taskId, violations, score, blocked } ← on block only
MemoryFlushed { agentId }
AgentCompleted { taskId, agentId, totalIterations, totalTokens, durationMs }
TaskCompleted { taskId, success }

All events carry the correct taskId for cross-event correlation. The LLMRequestStarted / LLMRequestCompleted pair share a requestId so you can measure exact LLM latency.

For direct EventBus access in Effect programs, the TypedEventHandler<T> helper lets you define handlers outside of inline callbacks:

import type { TypedEventHandler } from "@reactive-agents/core";
import { EventBus } from "@reactive-agents/core";
const onStep: TypedEventHandler<"ReasoningStepCompleted"> = (event) =>
Effect.log(`Step ${event.step} [${event.strategy}]: ${event.thought ?? event.action}`);
yield* EventBus.pipe(Effect.flatMap((eb) => eb.on("ReasoningStepCompleted", onStep)));

When .withObservability() is enabled, every phase is wrapped in a trace span:

execution.phase.bootstrap → span with taskId, agentId attributes
execution.phase.guardrail → span with phase timing
execution.phase.think → span with LLM latency
...

Counters are incremented on phase completion/error, and durations are recorded as histogram metrics. You get full distributed tracing across the entire lifecycle.

Every phase supports three hook timings:

TimingWhenUse Case
beforeBefore phase executesModify context, add data, log
afterAfter phase completesTransform output, record metrics
on-errorWhen phase failsCustom error handling, alerting
agent.withHook({
phase: "think",
timing: "before",
handler: (ctx) => {
console.log(`Iteration ${ctx.iteration}, tokens: ${ctx.tokensUsed}, cost: $${ctx.cost}`);
return Effect.succeed(ctx);
},
});
idle → bootstrapping → running → [paused] → [verifying] → flushing → completed
→ failed