Docs
Advanced Subsystems
Turn Engine

Turn Engine

⚠️

This is a v2 design — not yet implemented. See Architecture v2 for context.

The problem

Jan plans multiple agent cores: ReActCore, PlanExecuteCore, ReflexionCore, EmbodiedCore. Every core that calls an LLM needs the same per-turn mechanics: build messages, call LLM, dispatch tools, emit events, call hooks. Without a shared engine, each core reimplements this logic independently.

Core principle

The engine does one turn, the core decides the loop.


AgentRuntime
└── AgentCore (ReActCore, PlanExecuteCore, etc.)
└── calls TurnEngine::run_turn() per step
└── calls LLM, processes tools, emits events

The TurnEngine is an internal utility in jan-agent-core, not a framework trait.

API


pub struct TurnConfig {
pub tool_mode: ToolMode, // Enabled or Disabled
pub system_prompt: String,
}
pub enum TurnResult {
Complete(TurnStepOutput), // LLM returned text, done
ToolCallsProcessed(TurnStepOutput), // LLM returned tool calls, continue
}
pub struct TurnStepOutput {
pub response: String,
pub reasoning: String,
pub tool_results: Vec<ToolExecution>,
pub usage: TokenUsage,
}
pub struct TurnState {
messages: Vec<ChatMessage>,
user_message_added: bool, // prevents duplicate user messages
}

Execution flow


TurnEngine::run_turn(config, ctx, hooks, turn_state, turn_index)
├─ 1. Emit TurnStarted event
├─ 2. hooks.on_turn_start()
├─ 3. Build messages: system_prompt + accumulated history + user message
├─ 4. Call LLM (with or without tools based on config)
├─ 5. Process response:
│ ├─ Tool calls → ToolDispatchEngine.execute_all() → ToolCallsProcessed
│ └─ Text only → Complete
├─ 6. hooks.on_turn_complete()
└─ 7. Emit TurnCompleted event

How each core uses the engine

ReActCore — loop until done


impl AgentCore for ReActCore {
async fn run_turn(&mut self, input: TurnInput, ctx: &mut AgentContext)
-> Result<TurnOutput, AgentError>
{
let mut state = TurnState::new();
for turn_index in 0..self.max_turns {
let config = TurnConfig {
tool_mode: ToolMode::Enabled,
system_prompt: self.system_prompt.clone(),
};
match TurnEngine::run_turn(&config, ctx, &self.hooks, &mut state, turn_index).await? {
TurnResult::Complete(output) => return Ok(output.into()),
TurnResult::ToolCallsProcessed(_) => continue,
}
}
}
}

PlanExecuteCore — two phases


impl AgentCore for PlanExecuteCore {
async fn run_turn(&mut self, input: TurnInput, ctx: &mut AgentContext)
-> Result<TurnOutput, AgentError>
{
// Phase 1: Planning — tools disabled
let plan_config = TurnConfig { tool_mode: ToolMode::Disabled, ... };
let plan = TurnEngine::run_turn(&plan_config, ctx, ...).await?;
// Phase 2: Execution — tools enabled, per step
let exec_config = TurnConfig { tool_mode: ToolMode::Enabled, ... };
for step in parse_plan(&plan).steps {
TurnEngine::run_turn(&exec_config, ctx, ...).await?;
}
}
}

EmbodiedCore — sensor injection


impl AgentCore for EmbodiedCore {
async fn run_turn(&mut self, input: EmbodiedInput, ctx: &mut AgentContext)
-> Result<TurnOutput, AgentError>
{
let config = TurnConfig {
tool_mode: ToolMode::Enabled,
system_prompt: format!("{}\n\nSensors:\n{}", self.prompt, input.sensor_readings),
};
// Single turn per cycle — embodied core runs at control_frequency
TurnEngine::run_turn(&config, ctx, ...).await
}
}

Streaming

The TurnEngine emits AgentEvents through ctx.events at each step. No separate streaming API needed — Jan's event system handles it:

  • TextDelta(chunk) — as LLM tokens arrive
  • ToolCallStart { ... } — before tool execution
  • ToolCallEnd { ... } — after tool execution
  • TurnCompleted { ... } — turn done

The UI subscribes to events and renders incrementally. Streaming happens as a side effect of event emission.

Context window management

The TurnEngine does not manage context limits. Each core trims turn_state.messages as needed:


// Utility functions (not traits)
pub fn trim_to_budget(messages: &[ChatMessage], budget: u32, tokenizer: &dyn Tokenizer)
-> Vec<ChatMessage>;
pub async fn compact_context(messages: &mut Vec<ChatMessage>, threshold: f32, budget: u32,
provider: &dyn LlmProvider) -> Result<(), AgentError>;

  • ReActCore: compacts when nearing budget
  • PlanExecuteCore: resets state between plan steps
  • EmbodiedCore: aggressively trims to keep only latest sensor reading