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 arriveToolCallStart { ... }— before tool executionToolCallEnd { ... }— after tool executionTurnCompleted { ... }— 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