1use std::collections::HashMap;
2use std::path::PathBuf;
3use std::sync::Arc;
4
5use anyhow::Context;
6use futures::StreamExt;
7use futures::future::{AbortHandle, Abortable, BoxFuture, try_join_all};
8use roder_api::catalog::{
9 EDIT_TOOL_EDIT, EDIT_TOOL_PATCH, PROVIDER_GEMINI, REASONING_NONE, built_in_model_profile,
10 built_in_model_profile_for_provider, lookup_model,
11};
12use roder_api::context::PolicyGate;
13use roder_api::events::*;
14use roder_api::extension::ExtensionRegistry;
15use roder_api::inference::{
16 AgentInferenceRequest, HostedWebSearchConfig, HostedWebSearchMode, InferenceEngine,
17 InferenceEvent, InferenceTurnContext, InstructionBundle, ModelHarnessProfile,
18 ModelSchemaPolicy, ModelSelection, OutputConfig, ReasoningConfig, RuntimeHints, RuntimeProfile,
19 TokenUsage, ToolCallCompleted, ToolSearchConfig, ToolSearchConfigOverlay,
20 finish_reason_from_stop_reason,
21};
22use roder_api::inference_routing::{InferenceRoutingOutcome, ModelSelectionMode};
23use roder_api::policy_mode::{PolicyDecision, PolicyMode};
24use roder_api::reliability::{
25 ReliabilityContext, ReliabilityDetails, ReliabilityErrorClass, ReliabilityLimitRecorded,
26 ReliabilityRequestPolicy, ReliabilityRetryDecision, ReliabilityRetryRecorded,
27 provider_retry_delay_ms,
28};
29use roder_api::remote_runner::{
30 RemoteRunnerProvider, RemoteRunnerSession, RemoteWorkspace, RunnerDestination,
31 RunnerSessionState, ThreadRunnerBinding,
32};
33use roder_api::subagents::SubagentDefinition;
34use roder_api::teams::TeamMemberStatus;
35use roder_api::thread::{
36 ThreadItemEvent, ThreadItemEventKind, ThreadMetadata, ThreadSnapshot, ThreadStore,
37 ThreadUsageMetadata, is_synthetic_event_thread_id, validate_thread_workspace,
38};
39use roder_api::tools::{ToolCall, ToolChoice, ToolExecutionContext, ToolRegistry, ToolResult};
40use roder_api::transcript::{
41 AssistantMessage, ErrorRecord, InputImage, ReasoningSummary, ToolCallRecord, ToolResultRecord,
42 TranscriptItem, UserMessage,
43};
44use roder_sandbox::ScopedFilesystem;
45use roder_sandbox::process::LocalProcessRunner;
46use roder_skills::{SkillRegistry, SkillRegistryOptions};
47use time::{Duration, OffsetDateTime};
48use tokio::sync::{Mutex, RwLock, oneshot};
49
50use crate::artifacts::{
51 ContextArtifactStore as FilesystemContextArtifactStore, default_context_artifact_dir,
52};
53use crate::bus::EventBus;
54use crate::dynamic_workflows::{
55 DynamicWorkflowEffortProfile, RuntimeDynamicWorkflowConfig, WorkflowTriggerDecision,
56 classify_workflow_trigger, ultracode_reasoning_level_for_model,
57};
58use crate::fake_provider::FakeInferenceEngine;
59use crate::goals::RuntimeGoalController;
60use crate::inference_routing::{
61 InferenceRoutingRequest, RuntimeInferenceRouterConfig, collect_inference_routing_candidates,
62 route_inference_selection, transcript_failure_count_since,
63};
64use crate::instructions::{
65 apply_agent_swarm_mode, apply_model_instruction_overlay, apply_plan_mode, apply_runtime_profile,
66 apply_task_ledger_required, apply_thread_developer_instructions, apply_turn_developer_context,
67};
68use crate::policy_gate::DefaultPolicyGate;
69use crate::reliability::{
70 ReliabilityLimitHit, RuntimeReliabilityConfig, TurnReliabilityState,
71 provider_stream_retry_cause,
72};
73pub use crate::speed_policy::RuntimeSpeedPolicyConfig;
74use crate::speed_policy::{SpeedPolicyState, reasoning_from_decision};
75use crate::subagent_traces::{RuntimeAgentSwarmProgressSink, RuntimeSubagentTraceSink};
76use crate::teams::{TeamManager, TeamMemberStartRequest, TeamStartRequest, TeamState};
77use crate::thread_item_cache::{ThreadItemCache, ThreadItemCacheEntry};
78use crate::verification_gate::VerificationGateState;
79
80const MAX_TOOL_ROUNDS_PER_TURN: usize = 1024;
81const EVENT_BUS_CAPACITY: usize = 16_384;
91const FINAL_ANSWER_PHASE: &str = "final_answer";
92pub(crate) const TASK_LEDGER_TOOL_NAME: &str = "task_ledger.update";
93const TASK_LEDGER_COMPLETION_REMINDER_LIMIT: u8 = 2;
94const TASK_LEDGER_SCOREABLE_CHECKPOINT_SECONDS: u64 = 180;
95const TASK_LEDGER_SCOREABLE_CHECKPOINT_LIMIT: u8 = 1;
96pub(crate) const MIN_CHILD_DEADLINE_SECONDS: u64 = 2;
97const MODEL_PROFILE_TRACE_KIND: &str = "model_profile_segment";
98const MODEL_SWITCH_SUMMARY_PREFIX: &str = "Model switch summary:";
99
100#[derive(Clone, Copy, Debug, Eq, PartialEq)]
101enum InferenceTimeoutAction {
102 ScoreableCheckpoint,
103 Finalization,
104}
105
106#[derive(Debug, Clone)]
107pub struct RuntimeConfig {
108 pub default_provider: String,
109 pub default_model: String,
110 pub reasoning: Option<String>,
111 pub auto_compact_token_limit: Option<u32>,
112 pub file_backed_dynamic_context: bool,
113 pub hosted_web_search: HostedWebSearchConfig,
114 pub tool_search: ToolSearchConfig,
115 pub provider_tool_search: HashMap<String, ToolSearchConfigOverlay>,
116 pub model_tool_search: HashMap<String, ToolSearchConfigOverlay>,
117 pub model_edit_tools: HashMap<String, String>,
118 pub model_parallel_tool_calls: HashMap<String, bool>,
119 pub model_profiles: HashMap<String, ModelHarnessProfile>,
120 pub tool_allowlist: Vec<String>,
121 pub external_tool_timeout_seconds: u64,
123 pub command_shell: String,
124 pub workspace: Option<String>,
125 pub policy_mode: PolicyMode,
126 pub agent_swarm_mode: bool,
130 pub runtime_profile: RuntimeProfile,
131 pub inference_router: RuntimeInferenceRouterConfig,
132 pub speed_policy: RuntimeSpeedPolicyConfig,
133 pub dynamic_workflows: RuntimeDynamicWorkflowConfig,
134 pub reliability: RuntimeReliabilityConfig,
135 pub turn_deadline_seconds: Option<u64>,
136 pub remote_runner_destination: Option<RunnerDestination>,
137 pub team_data_dir: Option<PathBuf>,
138 pub roadmap_data_dir: Option<PathBuf>,
139 pub media_generation: crate::media_generation::RuntimeMediaGenerationConfig,
140}
141
142impl Default for RuntimeConfig {
143 fn default() -> Self {
144 Self {
145 default_provider: roder_api::catalog::PROVIDER_MOCK.to_string(),
146 default_model: "mock".to_string(),
147 reasoning: None,
148 auto_compact_token_limit: None,
149 file_backed_dynamic_context: true,
150 hosted_web_search: HostedWebSearchConfig::cached(),
151 tool_search: ToolSearchConfig::default(),
152 provider_tool_search: HashMap::new(),
153 model_tool_search: HashMap::new(),
154 model_edit_tools: HashMap::new(),
155 model_parallel_tool_calls: HashMap::new(),
156 model_profiles: HashMap::new(),
157 tool_allowlist: Vec::new(),
158 external_tool_timeout_seconds: DEFAULT_EXTERNAL_TOOL_TIMEOUT_SECONDS,
159 command_shell: roder_api::command_shell::default_command_shell(),
160 workspace: None,
161 policy_mode: PolicyMode::Default,
162 agent_swarm_mode: false,
163 runtime_profile: RuntimeProfile::Interactive,
164 inference_router: RuntimeInferenceRouterConfig::default(),
165 speed_policy: RuntimeSpeedPolicyConfig::default(),
166 dynamic_workflows: RuntimeDynamicWorkflowConfig::default(),
167 reliability: RuntimeReliabilityConfig::default(),
168 turn_deadline_seconds: None,
169 remote_runner_destination: None,
170 team_data_dir: None,
171 roadmap_data_dir: None,
172 media_generation: crate::media_generation::RuntimeMediaGenerationConfig::default(),
173 }
174 }
175}
176
177#[derive(Debug, Clone)]
178pub struct StartTurnRequest {
179 pub thread_id: ThreadId,
180 pub message: String,
181 pub images: Vec<InputImage>,
182 pub provider_override: Option<String>,
183 pub model_override: Option<String>,
184 pub reasoning_override: Option<String>,
185 pub workspace: String,
186 pub instructions: InstructionBundle,
187 pub developer_context: Option<String>,
193 pub task_ledger_required: bool,
194}
195
196#[derive(Debug, Clone)]
197pub struct CreateThreadRequest {
198 pub title: Option<String>,
199 pub workspace: String,
200 pub workspace_id: Option<String>,
201 pub root_id: Option<String>,
202 pub provider: Option<String>,
203 pub model: Option<String>,
204 pub selection_mode: Option<ModelSelectionMode>,
205 pub tool_allowlist: Vec<String>,
207 pub developer_instructions: Option<String>,
209 pub external_tools: Vec<roder_api::tools::ToolSpec>,
211 pub runner: Option<ThreadRunnerSelection>,
213}
214
215#[derive(Debug, Clone)]
221pub struct ThreadRunnerSelection {
222 pub provider_id: String,
223 pub config: serde_json::Value,
224 pub workspace: String,
226 pub read_roots: Vec<String>,
232}
233
234#[derive(Debug, Clone, PartialEq, Eq)]
235pub struct PendingPlanExit {
236 pub thread_id: ThreadId,
237 pub turn_id: TurnId,
238 pub request_id: String,
239 pub target_mode: PolicyMode,
240 pub plan_summary: Option<String>,
241 pub next_steps: Vec<String>,
242 pub requested_at: OffsetDateTime,
243 pub expires_at: Option<OffsetDateTime>,
244}
245
246pub(crate) struct PendingToolApproval {
247 pub(crate) thread_id: ThreadId,
248 pub(crate) turn_id: TurnId,
249 pub(crate) tool_id: String,
250 pub(crate) tool_name: String,
251 pub(crate) call: roder_api::tools::ToolCall,
252 pub(crate) tx: oneshot::Sender<bool>,
253}
254
255pub(crate) struct PendingUserInput {
256 pub(crate) thread_id: ThreadId,
257 pub(crate) turn_id: TurnId,
258 pub(crate) tx: oneshot::Sender<serde_json::Value>,
259}
260
261#[derive(Debug, Clone, PartialEq, Eq)]
263pub struct ExternalToolResolution {
264 pub output: String,
265 pub is_error: bool,
266}
267
268pub(crate) struct PendingExternalToolCall {
269 pub(crate) thread_id: ThreadId,
270 pub(crate) turn_id: TurnId,
271 pub(crate) tool_id: String,
272 pub(crate) tool_name: String,
273 pub(crate) tx: oneshot::Sender<ExternalToolResolution>,
274}
275
276#[derive(Clone)]
277struct ActiveTurnHandle {
278 thread_id: ThreadId,
279 abort: AbortHandle,
280 steers: Arc<Mutex<Vec<UserMessage>>>,
281}
282
283#[derive(Debug, Clone, Default, PartialEq, Eq)]
284pub struct ThreadActivity {
285 pub active_turn_id: Option<TurnId>,
286 pub active_flags: Vec<String>,
287}
288
289#[derive(Debug, Clone, Default)]
291pub(crate) struct ThreadTurnOverrides {
292 pub(crate) tool_allowlist: Vec<String>,
293 pub(crate) developer_instructions: Option<String>,
294 pub(crate) external_tools: Vec<roder_api::tools::ToolSpec>,
295}
296
297#[derive(Debug, Clone, Copy, PartialEq, Eq)]
298pub(crate) enum TurnRunOutcome {
299 Completed,
300 Stopped,
301}
302
303impl PendingPlanExit {
304 pub fn new(
305 thread_id: ThreadId,
306 turn_id: TurnId,
307 request_id: String,
308 target_mode: PolicyMode,
309 plan_summary: Option<String>,
310 next_steps: Vec<String>,
311 ) -> Self {
312 let requested_at = OffsetDateTime::now_utc();
313 Self {
314 thread_id,
315 turn_id,
316 request_id,
317 target_mode,
318 plan_summary,
319 next_steps,
320 requested_at,
321 expires_at: Some(requested_at + default_plan_exit_timeout()),
322 }
323 }
324
325 pub fn is_expired(&self, now: OffsetDateTime) -> bool {
326 self.expires_at.is_some_and(|expires_at| now >= expires_at)
327 }
328}
329
330pub fn default_plan_exit_timeout() -> Duration {
331 Duration::minutes(10)
332}
333
334pub const DEFAULT_EXTERNAL_TOOL_TIMEOUT_SECONDS: u64 = 300;
335
336pub struct Runtime {
337 pub bus: EventBus,
338 pub registry: ExtensionRegistry,
339 config: RwLock<RuntimeConfig>,
340 pending_plan_exit: RwLock<Option<PendingPlanExit>>,
341 pub(crate) pending_tool_approvals: Mutex<HashMap<String, PendingToolApproval>>,
342 pub(crate) pending_user_inputs: Mutex<HashMap<String, PendingUserInput>>,
343 pub(crate) pending_external_tool_calls: Mutex<HashMap<String, PendingExternalToolCall>>,
344 active_turns: RwLock<HashMap<TurnId, ActiveTurnHandle>>,
345 workspace: PathBuf,
346 teams: TeamManager,
347 pub(crate) roadmaps: Mutex<roder_roadmap::RoadmapRuntime>,
348 pub(crate) goals: Arc<RuntimeGoalController>,
349 context_artifacts: roder_api::artifacts::ContextArtifactStore,
350 pub(crate) thread_store: Option<Arc<dyn ThreadStore>>,
351 thread_item_cache: Mutex<ThreadItemCache>,
352 pub(crate) tool_registry: ToolRegistry,
353 media_generation: Arc<crate::media_generation::MediaGenerationService>,
354 pub(crate) skills: RwLock<SkillRegistry>,
355 event_sink_dispatcher: tokio::sync::OnceCell<crate::event_sink_dispatch::EventSinkDispatcher>,
358 pub(crate) compaction_hysteresis: std::sync::Mutex<HashMap<ThreadId, u32>>,
359 agent_swarm_modes: RwLock<HashMap<ThreadId, bool>>,
365}
366
367impl Runtime {
368 pub fn new(registry: ExtensionRegistry, config: RuntimeConfig) -> anyhow::Result<Self> {
369 if registry.inference_engines.is_empty() {
370 anyhow::bail!("at least one inference engine must be registered");
371 }
372 validate_runtime_config_reasoning(&config)?;
373 validate_runtime_inference_router_config(®istry, &config)?;
374
375 let bus = EventBus::new(EVENT_BUS_CAPACITY);
376 let thread_store = registry
377 .thread_stores
378 .first()
379 .map(|factory| factory.create());
380 let mut tool_registry = ToolRegistry::default();
381 for contributor in ®istry.tools {
382 contributor
383 .contribute(&mut tool_registry)
384 .with_context(|| format!("tool contributor {} failed", contributor.id()))?;
385 }
386 crate::agent_control_tools::contribute_agent_control_tools(&mut tool_registry)?;
387
388 let media_generation = Arc::new(crate::media_generation::MediaGenerationService::new(
389 registry.media_generator_providers.clone(),
390 config.media_generation.clone(),
391 ));
392 tool_registry.replace(Arc::new(
393 crate::media_generation::MediaGenerateImageTool::new(media_generation.clone()),
394 ));
395
396 let team_data_dir = config.team_data_dir.clone();
397 let workspace = config
398 .workspace
399 .clone()
400 .map(PathBuf::from)
401 .unwrap_or(std::env::current_dir()?);
402 let roadmap_data_dir = config
403 .roadmap_data_dir
404 .clone()
405 .unwrap_or_else(|| workspace.join(".roder"));
406 let context_artifacts = thread_store
407 .as_ref()
408 .and_then(|store| store.context_artifact_store())
409 .or_else(|| {
410 thread_store
411 .as_ref()
412 .and_then(|store| store.local_thread_root())
413 .map(FilesystemContextArtifactStore::shared_thread_scoped)
414 })
415 .unwrap_or_else(|| {
416 FilesystemContextArtifactStore::shared_legacy(default_context_artifact_dir())
417 });
418 let goals = Arc::new(RuntimeGoalController::new(
419 bus.clone(),
420 thread_store.clone(),
421 ));
422 let runtime = Self {
423 bus,
424 registry,
425 config: RwLock::new(config),
426 pending_plan_exit: RwLock::new(None),
427 pending_tool_approvals: Mutex::new(HashMap::new()),
428 pending_user_inputs: Mutex::new(HashMap::new()),
429 pending_external_tool_calls: Mutex::new(HashMap::new()),
430 active_turns: RwLock::new(HashMap::new()),
431 workspace: workspace.clone(),
432 teams: TeamManager::new(
433 team_data_dir.unwrap_or_else(crate::teams::default_team_data_dir),
434 ),
435 roadmaps: Mutex::new(roder_roadmap::RoadmapRuntime::new(
436 workspace,
437 roadmap_data_dir,
438 )),
439 goals,
440 context_artifacts,
441 thread_store,
442 thread_item_cache: Mutex::new(ThreadItemCache::default()),
443 tool_registry,
444 media_generation,
445 skills: RwLock::new(SkillRegistry::load(SkillRegistryOptions::new(
446 PathBuf::new(),
447 ))),
448 event_sink_dispatcher: tokio::sync::OnceCell::new(),
449 compaction_hysteresis: crate::compaction_runtime::compaction_hysteresis_state(),
450 agent_swarm_modes: RwLock::new(HashMap::new()),
451 };
452 runtime.bus.emit(RoderEvent::RuntimeStarted(RuntimeStarted {
453 timestamp: OffsetDateTime::now_utc(),
454 }));
455 for manifest in &runtime.registry.manifests {
456 runtime
457 .bus
458 .emit(RoderEvent::ExtensionRegistered(ExtensionRegistered {
459 extension_id: manifest.id.clone(),
460 timestamp: OffsetDateTime::now_utc(),
461 }));
462 }
463 Ok(runtime)
464 }
465
466 pub fn from_engine(engine: Arc<dyn InferenceEngine>) -> anyhow::Result<Self> {
467 let mut builder = roder_api::extension::ExtensionRegistryBuilder::new();
468 builder.inference_engine(engine);
469 Self::new(builder.build()?, RuntimeConfig::default())
470 }
471
472 pub fn fake() -> anyhow::Result<Self> {
473 Self::from_engine(Arc::new(FakeInferenceEngine))
474 }
475
476 pub fn subscribe_events(&self) -> tokio::sync::broadcast::Receiver<EventEnvelope> {
477 self.bus.subscribe()
478 }
479
480 pub fn registry(&self) -> &ExtensionRegistry {
481 &self.registry
482 }
483
484 pub fn media_generation(&self) -> Arc<crate::media_generation::MediaGenerationService> {
485 self.media_generation.clone()
486 }
487
488 pub fn context_artifacts(&self) -> roder_api::artifacts::ContextArtifactStore {
489 self.context_artifacts.clone()
490 }
491
492 pub async fn execute_workflow_tool(
493 &self,
494 thread_id: ThreadId,
495 tool_name: &str,
496 arguments: serde_json::Value,
497 ) -> anyhow::Result<ToolResult> {
498 let Some(executor) = self.tool_registry.get(tool_name) else {
499 anyhow::bail!("tool not found: {tool_name}");
500 };
501 let tool_call = ToolCall {
502 id: format!("slash-{tool_name}"),
503 name: tool_name.to_string(),
504 raw_arguments: serde_json::to_string(&arguments)?,
505 arguments,
506 thread_id: thread_id.clone(),
507 turn_id: "slash-command".to_string(),
508 };
509 let runtime_config = self.status().await;
510 let ctx = self.tool_execution_context(
511 thread_id,
512 "slash-command".to_string(),
513 runtime_config.policy_mode,
514 runtime_config.workspace.as_deref(),
515 Some(&runtime_config.command_shell),
516 );
517 executor.execute(ctx, tool_call).await
518 }
519
520 pub(crate) fn tool_execution_context(
521 &self,
522 thread_id: ThreadId,
523 turn_id: TurnId,
524 mode: PolicyMode,
525 workspace: Option<&str>,
526 command_shell: Option<&str>,
527 ) -> ToolExecutionContext {
528 let mut ctx = ToolExecutionContext::new(thread_id, turn_id, mode)
529 .with_command_shell(command_shell.unwrap_or_default())
530 .with_process_runner(Arc::new(LocalProcessRunner))
531 .with_context_artifacts(self.context_artifacts.backend())
532 .with_goal_controller(self.goals.clone())
533 .with_subagent_trace_sink(Arc::new(RuntimeSubagentTraceSink::new(
534 self.bus.clone(),
535 self.thread_store.clone(),
536 )))
537 .with_swarm_progress_sink(Arc::new(RuntimeAgentSwarmProgressSink::new(
538 self.bus.clone(),
539 self.thread_store.clone(),
540 )));
541 if let Some(workspace) = workspace {
542 ctx = ctx.with_workspace_handle(Arc::new(ScopedFilesystem::new(workspace)));
543 }
544 ctx
545 }
546
547 pub async fn status(&self) -> RuntimeConfig {
548 self.config.read().await.clone()
549 }
550
551 pub async fn set_skills(&self, skills: SkillRegistry) {
552 *self.skills.write().await = skills;
553 }
554
555 pub async fn skills_snapshot(&self) -> SkillRegistry {
556 self.skills.read().await.clone()
557 }
558
559 pub fn workspace(&self) -> PathBuf {
560 self.workspace.clone()
561 }
562
563 pub async fn set_remote_runner_destination(&self, destination: Option<RunnerDestination>) {
564 let lifecycle = destination.as_ref().map(|destination| RunnerLifecycle {
565 destination_id: destination.id.clone(),
566 provider_id: destination.provider_id.clone(),
567 state: "configured".to_string(),
568 session_id: None,
569 timestamp: OffsetDateTime::now_utc(),
570 });
571 self.config.write().await.remote_runner_destination = destination;
572 if let Some(lifecycle) = lifecycle {
573 self.emit(RoderEvent::RunnerLifecycle(lifecycle)).await;
574 } else {
575 self.emit(RoderEvent::RunnerLifecycle(RunnerLifecycle {
576 destination_id: "local".to_string(),
577 provider_id: "local".to_string(),
578 state: "local_fallback".to_string(),
579 session_id: None,
580 timestamp: OffsetDateTime::now_utc(),
581 }))
582 .await;
583 }
584 }
585
586 pub async fn set_file_backed_dynamic_context(&self, enabled: bool) -> RuntimeConfig {
587 let mut cfg = self.config.write().await;
588 cfg.file_backed_dynamic_context = enabled;
589 cfg.clone()
590 }
591
592 pub async fn set_command_shell(&self, shell: String) -> RuntimeConfig {
593 let mut cfg = self.config.write().await;
594 cfg.command_shell = shell;
595 cfg.clone()
596 }
597
598 pub async fn pending_plan_exit(&self) -> Option<PendingPlanExit> {
599 let mut pending = self.pending_plan_exit.write().await;
600 let current = pending.clone()?;
601 if !current.is_expired(OffsetDateTime::now_utc()) {
602 return Some(current);
603 }
604 *pending = None;
605 drop(pending);
606 self.emit_plan_exit_resolved(¤t, false, self.status().await.policy_mode)
607 .await;
608 None
609 }
610
611 pub async fn set_policy_mode(
612 &self,
613 mode: PolicyMode,
614 reason: Option<String>,
615 ) -> anyhow::Result<RuntimeConfig> {
616 let mut cfg = self.config.write().await;
617 let previous_mode = cfg.policy_mode;
618 cfg.policy_mode = mode;
619 let next = cfg.clone();
620 drop(cfg);
621 self.emit(RoderEvent::PolicyModeChanged(PolicyModeChanged {
622 thread_id: "runtime".to_string(),
623 turn_id: None,
624 previous_mode,
625 new_mode: mode,
626 reason,
627 timestamp: OffsetDateTime::now_utc(),
628 }))
629 .await;
630 self.auto_resolve_pending_tool_approvals_for_mode(mode)
631 .await;
632 Ok(next)
633 }
634
635 pub async fn set_agent_swarm_mode(
639 &self,
640 enabled: bool,
641 trigger: roder_api::subagents::AgentSwarmModeTrigger,
642 ) -> anyhow::Result<RuntimeConfig> {
643 let mut cfg = self.config.write().await;
644 cfg.agent_swarm_mode = enabled;
645 let next = cfg.clone();
646 drop(cfg);
647 self.emit(RoderEvent::AgentSwarmModeChanged(
648 roder_api::subagents::AgentSwarmModeChanged {
649 thread_id: "runtime".to_string(),
650 turn_id: None,
651 enabled,
652 trigger,
653 timestamp: OffsetDateTime::now_utc(),
654 },
655 ))
656 .await;
657 Ok(next)
658 }
659
660 pub async fn set_agent_swarm_mode_for_thread(
666 &self,
667 thread_id: &str,
668 enabled: bool,
669 trigger: roder_api::subagents::AgentSwarmModeTrigger,
670 ) -> bool {
671 {
672 let mut modes = self.agent_swarm_modes.write().await;
673 modes.insert(thread_id.to_string(), enabled);
674 }
675 self.emit(RoderEvent::AgentSwarmModeChanged(
676 roder_api::subagents::AgentSwarmModeChanged {
677 thread_id: thread_id.to_string(),
678 turn_id: None,
679 enabled,
680 trigger,
681 timestamp: OffsetDateTime::now_utc(),
682 },
683 ))
684 .await;
685 enabled
686 }
687
688 pub async fn effective_agent_swarm_mode_for_thread(&self, thread_id: &str) -> bool {
692 if let Some(enabled) = self.agent_swarm_modes.read().await.get(thread_id).copied() {
693 return enabled;
694 }
695 self.status().await.agent_swarm_mode
696 }
697
698 pub async fn set_hosted_web_search(
699 &self,
700 mode: HostedWebSearchMode,
701 ) -> anyhow::Result<RuntimeConfig> {
702 let mut cfg = self.config.write().await;
703 cfg.hosted_web_search = HostedWebSearchConfig { mode };
704 Ok(cfg.clone())
705 }
706
707 async fn auto_resolve_pending_tool_approvals_for_mode(&self, mode: PolicyMode) {
708 let gate = DefaultPolicyGate::new();
709 let mut pending = self.pending_tool_approvals.lock().await;
710 let approval_ids = pending
711 .iter()
712 .filter_map(|(approval_id, approval)| {
713 let ctx = ToolExecutionContext::new(
714 approval.thread_id.clone(),
715 approval.turn_id.clone(),
716 mode,
717 );
718 matches!(
719 gate.decide(&approval.call, mode, &ctx),
720 PolicyDecision::AutoApproved { .. }
721 )
722 .then_some(approval_id.clone())
723 })
724 .collect::<Vec<_>>();
725 let approvals = approval_ids
726 .into_iter()
727 .filter_map(|approval_id| {
728 pending
729 .remove(&approval_id)
730 .map(|approval| (approval_id, approval))
731 })
732 .collect::<Vec<_>>();
733 drop(pending);
734
735 for (approval_id, approval) in approvals {
736 let ctx = ToolExecutionContext::new(
737 approval.thread_id.clone(),
738 approval.turn_id.clone(),
739 mode,
740 );
741 let decision = gate.decide(&approval.call, mode, &ctx);
742 self.emit(RoderEvent::PolicyDecisionRecorded(PolicyDecisionRecorded {
743 thread_id: approval.thread_id.clone(),
744 turn_id: approval.turn_id.clone(),
745 tool_id: approval.tool_id.clone(),
746 tool_name: approval.tool_name.clone(),
747 mode,
748 decision,
749 timestamp: OffsetDateTime::now_utc(),
750 }))
751 .await;
752 if mode == PolicyMode::Bypass {
753 self.emit(RoderEvent::PolicyBypassActive(PolicyBypassActive {
754 thread_id: approval.thread_id.clone(),
755 turn_id: approval.turn_id.clone(),
756 tool_id: approval.tool_id.clone(),
757 tool_name: approval.tool_name.clone(),
758 timestamp: OffsetDateTime::now_utc(),
759 }))
760 .await;
761 }
762 self.emit(RoderEvent::ApprovalResolved(ApprovalResolved {
763 thread_id: approval.thread_id,
764 turn_id: approval.turn_id,
765 approval_id,
766 tool_id: approval.tool_id,
767 tool_name: approval.tool_name,
768 approved: true,
769 timestamp: OffsetDateTime::now_utc(),
770 }))
771 .await;
772 let _ = approval.tx.send(true);
773 }
774 }
775
776 pub async fn record_pending_plan_exit(&self, pending: PendingPlanExit) {
777 *self.pending_plan_exit.write().await = Some(pending.clone());
778 self.emit(RoderEvent::PolicyExitPlanRequested(
779 PolicyExitPlanRequested {
780 thread_id: pending.thread_id,
781 turn_id: pending.turn_id,
782 request_id: pending.request_id,
783 target_mode: pending.target_mode,
784 plan_summary: pending.plan_summary,
785 next_steps: pending.next_steps,
786 timestamp: OffsetDateTime::now_utc(),
787 },
788 ))
789 .await;
790 }
791
792 pub async fn resolve_pending_plan_exit(
793 &self,
794 request_id: &str,
795 approved: bool,
796 ) -> anyhow::Result<Option<PendingPlanExit>> {
797 let mut pending = self.pending_plan_exit.write().await;
798 let Some(current) = pending.clone() else {
799 return Ok(None);
800 };
801 if current.request_id != request_id {
802 anyhow::bail!("pending plan exit request {request_id:?} was not found");
803 }
804 *pending = None;
805 drop(pending);
806
807 let approved = approved && !current.is_expired(OffsetDateTime::now_utc());
808 let resolved_mode = if approved {
809 let mut cfg = self.config.write().await;
810 let previous_mode = cfg.policy_mode;
811 cfg.policy_mode = current.target_mode;
812 drop(cfg);
813 self.emit(RoderEvent::PolicyModeChanged(PolicyModeChanged {
814 thread_id: current.thread_id.clone(),
815 turn_id: Some(current.turn_id.clone()),
816 previous_mode,
817 new_mode: current.target_mode,
818 reason: Some("approved plan exit".to_string()),
819 timestamp: OffsetDateTime::now_utc(),
820 }))
821 .await;
822 self.auto_resolve_pending_tool_approvals_for_mode(current.target_mode)
823 .await;
824 current.target_mode
825 } else {
826 self.status().await.policy_mode
827 };
828 self.emit_plan_exit_resolved(¤t, approved, resolved_mode)
829 .await;
830 Ok(Some(current))
831 }
832
833 pub async fn resolve_tool_approval(
834 &self,
835 approval_id: &str,
836 approved: bool,
837 ) -> anyhow::Result<bool> {
838 let pending = self.pending_tool_approvals.lock().await.remove(approval_id);
839 let Some(pending) = pending else {
840 return Ok(false);
841 };
842 self.emit(RoderEvent::ApprovalResolved(ApprovalResolved {
843 thread_id: pending.thread_id,
844 turn_id: pending.turn_id,
845 approval_id: approval_id.to_string(),
846 tool_id: pending.tool_id,
847 tool_name: pending.tool_name,
848 approved,
849 timestamp: OffsetDateTime::now_utc(),
850 }))
851 .await;
852 let _ = pending.tx.send(approved);
853 Ok(true)
854 }
855
856 pub async fn request_app_server_tool_approval(
857 &self,
858 call: ToolCall,
859 reason: Option<String>,
860 ) -> anyhow::Result<bool> {
861 let approval_id = call.id.clone();
862 let (tx, rx) = oneshot::channel();
863 self.pending_tool_approvals.lock().await.insert(
864 approval_id.clone(),
865 PendingToolApproval {
866 thread_id: call.thread_id.clone(),
867 turn_id: call.turn_id.clone(),
868 tool_id: call.id.clone(),
869 tool_name: call.name.clone(),
870 call: call.clone(),
871 tx,
872 },
873 );
874 self.emit(RoderEvent::ApprovalRequested(ApprovalRequested {
875 thread_id: call.thread_id.clone(),
876 turn_id: call.turn_id.clone(),
877 approval_id,
878 tool_id: call.id.clone(),
879 tool_name: call.name.clone(),
880 reason,
881 timestamp: OffsetDateTime::now_utc(),
882 }))
883 .await;
884 Ok(rx.await.unwrap_or(false))
885 }
886
887 pub async fn resolve_external_tool_call(
890 &self,
891 request_id: &str,
892 resolution: ExternalToolResolution,
893 ) -> anyhow::Result<bool> {
894 let pending = self
895 .pending_external_tool_calls
896 .lock()
897 .await
898 .remove(request_id);
899 let Some(pending) = pending else {
900 return Ok(false);
901 };
902 self.emit(RoderEvent::ExternalToolCallResolved(
903 ExternalToolCallResolved {
904 thread_id: pending.thread_id,
905 turn_id: pending.turn_id,
906 request_id: request_id.to_string(),
907 tool_id: pending.tool_id,
908 tool_name: pending.tool_name,
909 outcome: ExternalToolCallOutcome::Resolved,
910 is_error: resolution.is_error,
911 timestamp: OffsetDateTime::now_utc(),
912 },
913 ))
914 .await;
915 let _ = pending.tx.send(resolution);
916 Ok(true)
917 }
918
919 async fn cancel_pending_external_tool_calls_for_turn(&self, turn_id: &TurnId) {
922 let cancelled = {
923 let mut pending = self.pending_external_tool_calls.lock().await;
924 let request_ids = pending
925 .iter()
926 .filter(|(_, call)| &call.turn_id == turn_id)
927 .map(|(request_id, _)| request_id.clone())
928 .collect::<Vec<_>>();
929 request_ids
930 .into_iter()
931 .filter_map(|request_id| pending.remove(&request_id).map(|call| (request_id, call)))
932 .collect::<Vec<_>>()
933 };
934 for (request_id, call) in cancelled {
935 self.emit(RoderEvent::ExternalToolCallResolved(
936 ExternalToolCallResolved {
937 thread_id: call.thread_id,
938 turn_id: call.turn_id,
939 request_id,
940 tool_id: call.tool_id,
941 tool_name: call.tool_name,
942 outcome: ExternalToolCallOutcome::Cancelled,
943 is_error: true,
944 timestamp: OffsetDateTime::now_utc(),
945 },
946 ))
947 .await;
948 }
949 }
950
951 pub async fn resolve_user_input(
952 &self,
953 request_id: &str,
954 answers: serde_json::Value,
955 ) -> anyhow::Result<bool> {
956 let pending = self.pending_user_inputs.lock().await.remove(request_id);
957 let Some(pending) = pending else {
958 return Ok(false);
959 };
960 self.emit(RoderEvent::UserInputResolved(UserInputResolved {
961 thread_id: pending.thread_id,
962 turn_id: pending.turn_id,
963 request_id: request_id.to_string(),
964 answers: answers.clone(),
965 timestamp: OffsetDateTime::now_utc(),
966 }))
967 .await;
968 let _ = pending.tx.send(answers);
969 Ok(true)
970 }
971
972 async fn emit_plan_exit_resolved(
973 &self,
974 current: &PendingPlanExit,
975 approved: bool,
976 resolved_mode: PolicyMode,
977 ) {
978 self.emit(RoderEvent::PolicyExitPlanResolved(PolicyExitPlanResolved {
979 thread_id: current.thread_id.clone(),
980 turn_id: current.turn_id.clone(),
981 request_id: current.request_id.clone(),
982 approved,
983 target_mode: current.target_mode,
984 resolved_mode,
985 timestamp: OffsetDateTime::now_utc(),
986 }))
987 .await;
988 }
989
990 pub async fn select_provider(
991 &self,
992 provider: String,
993 model: Option<String>,
994 reasoning: Option<String>,
995 ) -> anyhow::Result<RuntimeConfig> {
996 let next = self
997 .preview_provider_selection(provider, model, reasoning)
998 .await?;
999 let mut cfg = self.config.write().await;
1000 *cfg = next;
1001 Ok(cfg.clone())
1002 }
1003
1004 pub async fn preview_provider_selection(
1005 &self,
1006 provider: String,
1007 model: Option<String>,
1008 reasoning: Option<String>,
1009 ) -> anyhow::Result<RuntimeConfig> {
1010 self.engine_for(&provider)?;
1011 let mut cfg = self.config.read().await.clone();
1012 cfg.default_provider = provider;
1013 if let Some(model) = model {
1014 cfg.default_model = model;
1015 }
1016 if let Some(reasoning) = reasoning {
1017 if reasoning == REASONING_NONE
1018 && !model_supports_reasoning(&cfg.default_model, &reasoning)
1019 {
1020 return Ok(cfg.clone());
1021 }
1022 validate_reasoning_effort(&cfg.default_model, &reasoning)?;
1023 cfg.reasoning = Some(reasoning);
1024 }
1025 Ok(cfg)
1026 }
1027
1028 pub async fn effective_reasoning(&self) -> String {
1029 let cfg = self.config.read().await;
1030 effective_reasoning_for_model(&cfg, &cfg.default_model)
1031 }
1032
1033 pub fn effective_reasoning_for_config(cfg: &RuntimeConfig) -> String {
1034 effective_reasoning_for_model(cfg, &cfg.default_model)
1035 }
1036
1037 pub async fn set_dynamic_workflow_effort(
1038 &self,
1039 effort_profile: DynamicWorkflowEffortProfile,
1040 ) -> RuntimeConfig {
1041 let mut cfg = self.config.write().await;
1042 cfg.dynamic_workflows.effort_profile = effort_profile;
1043 cfg.clone()
1044 }
1045
1046 pub async fn dynamic_workflow_trigger_decision(
1047 &self,
1048 message: &str,
1049 ) -> WorkflowTriggerDecision {
1050 let cfg = self.config.read().await;
1051 classify_workflow_trigger(message, &cfg.dynamic_workflows)
1052 }
1053
1054 pub async fn create_thread(&self, title: Option<String>) -> anyhow::Result<ThreadMetadata> {
1055 self.create_thread_with(CreateThreadRequest {
1056 title,
1057 workspace: self.workspace.display().to_string(),
1058 workspace_id: None,
1059 root_id: None,
1060 provider: None,
1061 model: None,
1062 selection_mode: None,
1063 tool_allowlist: Vec::new(),
1064 developer_instructions: None,
1065 external_tools: Vec::new(),
1066 runner: None,
1067 })
1068 .await
1069 }
1070
1071 async fn resolve_thread_runner_binding(
1077 &self,
1078 thread_id: &str,
1079 selection: ThreadRunnerSelection,
1080 ) -> anyhow::Result<ThreadRunnerBinding> {
1081 let provider = self
1082 .registry
1083 .remote_runner_providers
1084 .iter()
1085 .find(|provider| provider.id() == selection.provider_id)
1086 .cloned()
1087 .ok_or_else(|| {
1088 anyhow::anyhow!(
1089 "remote runner provider {:?} is not installed",
1090 selection.provider_id
1091 )
1092 })?;
1093 let workspace = selection.workspace.trim();
1094 anyhow::ensure!(
1095 std::path::Path::new(workspace).is_absolute(),
1096 "runner workspace must be an absolute path on the runner: {workspace:?}"
1097 );
1098 let mut read_roots = Vec::with_capacity(selection.read_roots.len());
1099 for read_root in &selection.read_roots {
1100 let trimmed = read_root.trim();
1101 anyhow::ensure!(
1102 std::path::Path::new(trimmed).is_absolute(),
1103 "runner read root must be an absolute path on the runner: {trimmed:?}"
1104 );
1105 read_roots.push(PathBuf::from(trimmed));
1106 }
1107 let destination = RunnerDestination {
1108 id: format!("thread-{thread_id}"),
1109 provider_id: selection.provider_id,
1110 config: selection.config,
1111 default_manifest: roder_api::remote_runner::RunnerManifest::default(),
1112 };
1113 provider.validate_destination(&destination).await?;
1114 Ok(ThreadRunnerBinding {
1115 destination,
1116 workspace: PathBuf::from(workspace),
1117 read_roots,
1118 })
1119 }
1120
1121 async fn synthesize_runtime_runner_binding(
1129 &self,
1130 thread_id: &str,
1131 destination: Option<RunnerDestination>,
1132 ) -> anyhow::Result<Option<ThreadRunnerBinding>> {
1133 let Some(destination) = destination else {
1134 return Ok(None);
1135 };
1136 let Some(provider) = self
1137 .registry
1138 .remote_runner_providers
1139 .iter()
1140 .find(|provider| provider.id() == destination.provider_id)
1141 else {
1142 return Ok(None);
1143 };
1144 let workspace = destination
1147 .config
1148 .get("working_dir")
1149 .and_then(serde_json::Value::as_str)
1150 .map(str::to_string)
1151 .or_else(|| provider.default_workspace());
1152 let Some(workspace) = workspace else {
1153 return Ok(None);
1154 };
1155 let selection = ThreadRunnerSelection {
1156 provider_id: destination.provider_id.clone(),
1157 config: destination.config.clone(),
1158 workspace,
1159 read_roots: Vec::new(),
1160 };
1161 Ok(Some(
1162 self.resolve_thread_runner_binding(thread_id, selection)
1163 .await?,
1164 ))
1165 }
1166
1167 pub async fn validate_thread_runner_selection(
1169 &self,
1170 selection: ThreadRunnerSelection,
1171 ) -> anyhow::Result<()> {
1172 self.resolve_thread_runner_binding("validate", selection)
1173 .await
1174 .map(|_| ())
1175 }
1176
1177 pub async fn create_thread_with(
1178 &self,
1179 req: CreateThreadRequest,
1180 ) -> anyhow::Result<ThreadMetadata> {
1181 let cfg = self.config.read().await.clone();
1182 let now = OffsetDateTime::now_utc();
1183 let workspace = validate_thread_workspace(&req.workspace)?;
1184 let provider = req.provider.unwrap_or(cfg.default_provider);
1185 let model = req.model.unwrap_or(cfg.default_model);
1186 let selection_mode = req
1187 .selection_mode
1188 .unwrap_or_else(|| ModelSelectionMode::manual(provider.clone(), model.clone(), None));
1189 let thread_id = uuid::Uuid::new_v4().to_string();
1190 let runner_binding = match req.runner {
1191 Some(selection) => Some(
1192 self.resolve_thread_runner_binding(&thread_id, selection)
1193 .await?,
1194 ),
1195 None => self
1202 .synthesize_runtime_runner_binding(&thread_id, cfg.remote_runner_destination.clone())
1203 .await?,
1204 };
1205 let runner_destination = runner_binding
1206 .as_ref()
1207 .map(|binding| binding.destination.clone())
1208 .or_else(|| cfg.remote_runner_destination.clone());
1209 let metadata = ThreadMetadata {
1210 thread_id,
1211 title: req.title,
1212 workspace,
1213 workspace_id: req.workspace_id,
1214 root_id: req.root_id,
1215 provider: Some(provider),
1216 model: Some(model),
1217 selection_mode: Some(selection_mode),
1218 tool_allowlist: req.tool_allowlist,
1219 developer_instructions: req.developer_instructions,
1220 external_tools: req.external_tools,
1221 runner_destination,
1222 runner_state: None,
1223 runner_binding,
1224 created_at: now,
1225 updated_at: now,
1226 message_count: 0,
1227 usage: None,
1228 parent_thread_id: None,
1229 forked_from_turn_id: None,
1230 workspace_fork: None,
1231 };
1232
1233 let metadata = if let Some(store) = &self.thread_store {
1234 store.create_thread(metadata).await?
1235 } else {
1236 metadata
1237 };
1238 self.emit(RoderEvent::ThreadCreated(ThreadCreated {
1239 thread_id: metadata.thread_id.clone(),
1240 timestamp: OffsetDateTime::now_utc(),
1241 }))
1242 .await;
1243 Ok(metadata)
1244 }
1245
1246 pub async fn list_threads(&self) -> anyhow::Result<Vec<ThreadMetadata>> {
1247 if let Some(store) = &self.thread_store {
1248 return store.list_threads().await;
1249 }
1250 Ok(Vec::new())
1251 }
1252
1253 pub async fn list_threads_page(
1254 &self,
1255 options: roder_api::thread::ThreadListOptions,
1256 ) -> anyhow::Result<roder_api::thread::ThreadListPage> {
1257 if let Some(store) = &self.thread_store {
1258 return store.list_threads_page(options).await;
1259 }
1260 Ok(roder_api::thread::ThreadListPage::default())
1261 }
1262
1263 pub async fn load_thread_metadata(
1264 &self,
1265 thread_id: &str,
1266 ) -> anyhow::Result<Option<roder_api::thread::ThreadMetadata>> {
1267 if let Some(store) = &self.thread_store {
1268 return store.load_thread_metadata(&thread_id.to_string()).await;
1269 }
1270 Ok(None)
1271 }
1272
1273 pub async fn archive_thread(&self, thread_id: &str) -> anyhow::Result<bool> {
1274 let archived = if let Some(store) = &self.thread_store {
1275 store.archive_thread(&thread_id.to_string()).await?
1276 } else {
1277 false
1278 };
1279 if archived {
1280 self.thread_item_cache
1281 .lock()
1282 .await
1283 .remove_thread(&thread_id.to_string());
1284 }
1285 Ok(archived)
1286 }
1287
1288 pub async fn start_team(&self, req: TeamStartRequest) -> anyhow::Result<TeamState> {
1289 let cfg = self.config.read().await.clone();
1290 let workspace = self.workspace.display().to_string();
1291 let lead_thread_id = match req.lead_thread_id {
1292 Some(thread_id) => thread_id,
1293 None => {
1294 self.create_thread_with(CreateThreadRequest {
1295 title: Some("Team lead".to_string()),
1296 workspace: workspace.clone(),
1297 workspace_id: None,
1298 root_id: None,
1299 provider: None,
1300 model: None,
1301 selection_mode: None,
1302 tool_allowlist: Vec::new(),
1303 developer_instructions: None,
1304 external_tools: Vec::new(),
1305 runner: None,
1306 })
1307 .await?
1308 .thread_id
1309 }
1310 };
1311 let team_id = uuid::Uuid::new_v4().to_string();
1312 let mut members = vec![crate::teams::lead_member(
1313 lead_thread_id.clone(),
1314 Some(cfg.default_provider.clone()),
1315 Some(cfg.default_model.clone()),
1316 cfg.policy_mode,
1317 )];
1318
1319 for (index, member) in req.members.into_iter().enumerate() {
1320 let thread = self
1321 .create_thread_with(CreateThreadRequest {
1322 title: Some(member.name.clone()),
1323 workspace: workspace.clone(),
1324 workspace_id: None,
1325 root_id: None,
1326 provider: member.model_provider.clone(),
1327 model: member.model.clone(),
1328 selection_mode: None,
1329 tool_allowlist: Vec::new(),
1330 developer_instructions: None,
1331 external_tools: Vec::new(),
1332 runner: None,
1333 })
1334 .await?;
1335 let member_id = format!("member-{}", index + 1);
1336 let descriptor = crate::teams::teammate_member(
1337 member_id.clone(),
1338 member.name,
1339 thread.thread_id.clone(),
1340 member.model_provider.or(thread.provider),
1341 member.model.or(thread.model),
1342 cfg.policy_mode,
1343 );
1344 self.emit(RoderEvent::TeamMemberStarted(TeamMemberStarted {
1345 team_id: team_id.clone(),
1346 member_id,
1347 member_thread_id: thread.thread_id,
1348 role: descriptor.role,
1349 name: descriptor.name.clone(),
1350 timestamp: OffsetDateTime::now_utc(),
1351 }))
1352 .await;
1353 members.push(descriptor);
1354 }
1355
1356 let now = OffsetDateTime::now_utc();
1357 let team = self
1358 .teams
1359 .insert(TeamState {
1360 id: team_id.clone(),
1361 lead_thread_id: lead_thread_id.clone(),
1362 display_mode: req.display_mode,
1363 members,
1364 mailbox: Vec::new(),
1365 tasks: Vec::new(),
1366 created_at: now,
1367 updated_at: now,
1368 })
1369 .await?;
1370 self.emit(RoderEvent::TeamStarted(TeamStarted {
1371 team_id,
1372 lead_thread_id,
1373 display_mode: team.display_mode,
1374 timestamp: OffsetDateTime::now_utc(),
1375 }))
1376 .await;
1377 Ok(team)
1378 }
1379
1380 pub async fn list_teams(&self) -> Vec<TeamState> {
1381 self.teams.list().await
1382 }
1383
1384 pub async fn read_team(&self, team_id: &str) -> Option<TeamState> {
1385 self.teams.get(team_id).await
1386 }
1387
1388 pub async fn start_team_member(
1389 &self,
1390 team_id: &str,
1391 req: TeamMemberStartRequest,
1392 ) -> anyhow::Result<TeamState> {
1393 let cfg = self.config.read().await.clone();
1394 let thread = self
1395 .create_thread_with(CreateThreadRequest {
1396 title: Some(req.name.clone()),
1397 workspace: self.workspace.display().to_string(),
1398 workspace_id: None,
1399 root_id: None,
1400 provider: req.model_provider.clone(),
1401 model: req.model.clone(),
1402 selection_mode: None,
1403 tool_allowlist: Vec::new(),
1404 developer_instructions: None,
1405 external_tools: Vec::new(),
1406 runner: None,
1407 })
1408 .await?;
1409 let team = self
1410 .read_team(team_id)
1411 .await
1412 .ok_or_else(|| anyhow::anyhow!("unknown team {team_id:?}"))?;
1413 let member_id = format!("member-{}", team.members.len());
1414 let descriptor = crate::teams::teammate_member(
1415 member_id.clone(),
1416 req.name,
1417 thread.thread_id.clone(),
1418 req.model_provider.or(thread.provider),
1419 req.model.or(thread.model),
1420 cfg.policy_mode,
1421 );
1422 let mut next = team;
1423 next.members.push(descriptor.clone());
1424 next.updated_at = OffsetDateTime::now_utc();
1425 let next = self.teams.insert(next).await?;
1426 self.emit(RoderEvent::TeamMemberStarted(TeamMemberStarted {
1427 team_id: next.id.clone(),
1428 member_id,
1429 member_thread_id: descriptor.thread_id,
1430 role: descriptor.role,
1431 name: descriptor.name,
1432 timestamp: OffsetDateTime::now_utc(),
1433 }))
1434 .await;
1435 Ok(next)
1436 }
1437
1438 pub async fn message_team_member(
1439 self: &Arc<Self>,
1440 team_id: &str,
1441 member_id: &str,
1442 message: String,
1443 ) -> anyhow::Result<TurnId> {
1444 let team = self
1445 .read_team(team_id)
1446 .await
1447 .ok_or_else(|| anyhow::anyhow!("unknown team {team_id:?}"))?;
1448 let member = team
1449 .members
1450 .iter()
1451 .find(|member| member.id == member_id)
1452 .ok_or_else(|| anyhow::anyhow!("unknown team member {member_id:?}"))?
1453 .clone();
1454 if member.status == TeamMemberStatus::Closed {
1455 anyhow::bail!("subagent {} is closed", member.name);
1456 }
1457 self.teams
1458 .append_mailbox_message(team_id, None, member_id.to_string(), message.clone())
1459 .await?;
1460 let workspace = self.workspace.display().to_string();
1461 let turn_id = if member.status == TeamMemberStatus::Running {
1462 if let Some(turn_id) = member.current_turn_id.clone() {
1463 self.steer_turn(
1464 member.thread_id.clone(),
1465 turn_id.clone(),
1466 message,
1467 Vec::new(),
1468 )
1469 .await?;
1470 turn_id
1471 } else {
1472 self.start_turn(StartTurnRequest {
1473 thread_id: member.thread_id.clone(),
1474 message,
1475 images: Vec::new(),
1476 provider_override: member.model_provider.clone(),
1477 model_override: member.model.clone(),
1478 reasoning_override: None,
1479 workspace: workspace.clone(),
1480 instructions: crate::default_instructions(),
1481 developer_context: None,
1482 task_ledger_required: false,
1483 })
1484 .await?
1485 }
1486 } else {
1487 self.start_turn(StartTurnRequest {
1488 thread_id: member.thread_id.clone(),
1489 message,
1490 images: Vec::new(),
1491 provider_override: member.model_provider.clone(),
1492 model_override: member.model.clone(),
1493 reasoning_override: None,
1494 workspace,
1495 instructions: crate::default_instructions(),
1496 developer_context: None,
1497 task_ledger_required: false,
1498 })
1499 .await?
1500 };
1501 let is_active = self.active_turns.read().await.contains_key(&turn_id);
1502 self.teams
1503 .update_member(team_id, member_id, |member| {
1504 if is_active {
1505 member.current_turn_id = Some(turn_id.clone());
1506 member.status = TeamMemberStatus::Running;
1507 } else {
1508 member.current_turn_id = None;
1509 member.status = TeamMemberStatus::Completed;
1510 }
1511 })
1512 .await?;
1513 if is_active {
1514 self.emit(RoderEvent::TeamMemberStatusChanged(
1515 TeamMemberStatusChanged {
1516 team_id: team_id.to_string(),
1517 member_id: member_id.to_string(),
1518 member_thread_id: member.thread_id,
1519 status: TeamMemberStatus::Running,
1520 timestamp: OffsetDateTime::now_utc(),
1521 },
1522 ))
1523 .await;
1524 } else {
1525 self.emit(RoderEvent::TeamMemberCompleted(TeamMemberCompleted {
1526 team_id: team_id.to_string(),
1527 member_id: member_id.to_string(),
1528 member_thread_id: member.thread_id,
1529 turn_id: Some(turn_id.clone()),
1530 status: TeamMemberStatus::Completed,
1531 timestamp: OffsetDateTime::now_utc(),
1532 }))
1533 .await;
1534 }
1535 Ok(turn_id)
1536 }
1537
1538 pub async fn set_team_member_policy_mode(
1539 &self,
1540 team_id: &str,
1541 member_id: &str,
1542 policy_mode: PolicyMode,
1543 ) -> anyhow::Result<TeamState> {
1544 self.teams
1545 .set_member_policy_mode(team_id, member_id, policy_mode)
1546 .await
1547 }
1548
1549 pub async fn interrupt_team_member(
1550 &self,
1551 team_id: &str,
1552 member_id: &str,
1553 ) -> anyhow::Result<Option<TurnId>> {
1554 let team = self
1555 .read_team(team_id)
1556 .await
1557 .ok_or_else(|| anyhow::anyhow!("unknown team {team_id:?}"))?;
1558 let member = team
1559 .members
1560 .iter()
1561 .find(|member| member.id == member_id)
1562 .ok_or_else(|| anyhow::anyhow!("unknown team member {member_id:?}"))?
1563 .clone();
1564 let Some(turn_id) = member.current_turn_id.clone() else {
1565 return Ok(None);
1566 };
1567 self.interrupt_turn(member.thread_id.clone(), turn_id.clone())
1568 .await?;
1569 self.teams
1570 .update_member(team_id, member_id, |member| {
1571 member.status = TeamMemberStatus::Interrupted;
1572 member.current_turn_id = None;
1573 })
1574 .await?;
1575 self.emit(RoderEvent::TeamMemberCompleted(TeamMemberCompleted {
1576 team_id: team_id.to_string(),
1577 member_id: member_id.to_string(),
1578 member_thread_id: member.thread_id,
1579 turn_id: Some(turn_id.clone()),
1580 status: TeamMemberStatus::Interrupted,
1581 timestamp: OffsetDateTime::now_utc(),
1582 }))
1583 .await;
1584 Ok(Some(turn_id))
1585 }
1586
1587 pub async fn close_team_member(
1588 &self,
1589 team_id: &str,
1590 member_id: &str,
1591 ) -> anyhow::Result<roder_api::teams::TeamMemberDescriptor> {
1592 let team = self
1593 .read_team(team_id)
1594 .await
1595 .ok_or_else(|| anyhow::anyhow!("unknown team {team_id:?}"))?;
1596 let member = team
1597 .members
1598 .iter()
1599 .find(|member| member.id == member_id)
1600 .ok_or_else(|| anyhow::anyhow!("unknown team member {member_id:?}"))?
1601 .clone();
1602 if member.role == roder_api::teams::TeamMemberRole::Lead {
1603 anyhow::bail!("team lead cannot be closed as a subagent");
1604 }
1605 let interrupted_turn_id = if member.status == TeamMemberStatus::Running {
1606 if let Some(turn_id) = member.current_turn_id.clone() {
1607 self.interrupt_turn(member.thread_id.clone(), turn_id.clone())
1608 .await?;
1609 Some(turn_id)
1610 } else {
1611 None
1612 }
1613 } else {
1614 member.current_turn_id.clone()
1615 };
1616 let updated = self
1617 .teams
1618 .update_member(team_id, member_id, |member| {
1619 member.status = TeamMemberStatus::Closed;
1620 member.current_turn_id = None;
1621 })
1622 .await?;
1623 let closed = updated
1624 .members
1625 .iter()
1626 .find(|member| member.id == member_id)
1627 .cloned()
1628 .ok_or_else(|| anyhow::anyhow!("closed team member disappeared"))?;
1629 self.emit(crate::agent_control_tools::closed_member_event(
1630 team_id.to_string(),
1631 &closed,
1632 interrupted_turn_id,
1633 ))
1634 .await;
1635 Ok(closed)
1636 }
1637
1638 pub async fn cleanup_team(&self, team_id: &str, force: bool) -> anyhow::Result<bool> {
1639 let Some(team) = self.read_team(team_id).await else {
1640 return Ok(false);
1641 };
1642 if !force
1643 && team
1644 .members
1645 .iter()
1646 .any(|member| member.status == TeamMemberStatus::Running)
1647 {
1648 anyhow::bail!("team {team_id:?} has active teammates; use forced cleanup");
1649 }
1650 let removed = self.teams.remove(team_id).await?.is_some();
1651 if removed {
1652 self.emit(RoderEvent::TeamCleanupCompleted(TeamCleanupCompleted {
1653 team_id: team_id.to_string(),
1654 forced: force,
1655 timestamp: OffsetDateTime::now_utc(),
1656 }))
1657 .await;
1658 }
1659 Ok(removed)
1660 }
1661
1662 pub async fn effective_policy_mode_for_thread(&self, thread_id: &str) -> PolicyMode {
1663 if let Some(mode) = self.teams.policy_mode_for_thread(thread_id).await {
1664 return mode;
1665 }
1666 self.status().await.policy_mode
1667 }
1668
1669 async fn complete_team_member_turn(
1670 &self,
1671 thread_id: &ThreadId,
1672 turn_id: &TurnId,
1673 status: TeamMemberStatus,
1674 ) -> anyhow::Result<()> {
1675 let Some((team_id, member)) = self
1676 .teams
1677 .complete_member_turn(thread_id, turn_id, status)
1678 .await?
1679 else {
1680 return Ok(());
1681 };
1682 self.emit(RoderEvent::TeamMemberCompleted(TeamMemberCompleted {
1683 team_id,
1684 member_id: member.id,
1685 member_thread_id: member.thread_id,
1686 turn_id: Some(turn_id.clone()),
1687 status,
1688 timestamp: OffsetDateTime::now_utc(),
1689 }))
1690 .await;
1691 Ok(())
1692 }
1693
1694 pub async fn load_thread(
1695 &self,
1696 thread_id: &ThreadId,
1697 ) -> anyhow::Result<Option<ThreadSnapshot>> {
1698 let loaded = if let Some(store) = &self.thread_store {
1699 store.load_thread(thread_id).await?
1700 } else {
1701 None
1702 };
1703 if loaded.is_some() {
1704 self.emit(RoderEvent::ThreadLoaded(ThreadLoaded {
1705 thread_id: thread_id.clone(),
1706 timestamp: OffsetDateTime::now_utc(),
1707 }))
1708 .await;
1709 }
1710 Ok(loaded)
1711 }
1712
1713 pub async fn workspace_for_thread(&self, thread_id: &ThreadId) -> anyhow::Result<String> {
1714 if let Some(store) = &self.thread_store {
1715 let snapshot = store
1716 .load_thread(thread_id)
1717 .await?
1718 .ok_or_else(|| anyhow::anyhow!("thread not found: {thread_id}"));
1719 match snapshot {
1720 Ok(snapshot) => {
1721 if let Some(metadata) = snapshot.metadata {
1722 if let Some(fork) = &metadata.workspace_fork
1725 && fork.status == roder_api::forks::ForkStatus::Active
1726 && !std::path::Path::new(&metadata.workspace).is_dir()
1727 {
1728 anyhow::bail!(
1729 "workspace fork {} is missing its workspace at {}; restore it or \
1730 remove the fork before running turns in this thread",
1731 fork.id,
1732 metadata.workspace
1733 );
1734 }
1735 return Ok(metadata.workspace);
1736 }
1737 eprintln!(
1738 "thread metadata missing while resolving workspace for {thread_id}; falling back to runtime workspace"
1739 );
1740 }
1741 Err(err) => {
1742 eprintln!(
1743 "thread missing while resolving workspace for {thread_id}: {err}; falling back to runtime workspace"
1744 );
1745 }
1746 }
1747 }
1748 Ok(self.workspace.display().to_string())
1749 }
1750
1751 async fn selection_mode_for_thread(
1752 &self,
1753 thread_id: &ThreadId,
1754 ) -> anyhow::Result<Option<ModelSelectionMode>> {
1755 let Some(store) = &self.thread_store else {
1756 return Ok(None);
1757 };
1758 Ok(store
1759 .load_thread(thread_id)
1760 .await?
1761 .and_then(|snapshot| snapshot.metadata)
1762 .and_then(|metadata| {
1763 metadata
1764 .selection_mode
1765 .or_else(|| match (metadata.provider, metadata.model) {
1766 (Some(provider), Some(model)) => {
1767 Some(ModelSelectionMode::manual(provider, model, None))
1768 }
1769 _ => None,
1770 })
1771 }))
1772 }
1773
1774 pub(crate) async fn thread_turn_overrides(
1776 &self,
1777 thread_id: &ThreadId,
1778 ) -> anyhow::Result<ThreadTurnOverrides> {
1779 let Some(store) = &self.thread_store else {
1780 return Ok(ThreadTurnOverrides::default());
1781 };
1782 Ok(store
1783 .load_thread_metadata(thread_id)
1784 .await?
1785 .map(|metadata| ThreadTurnOverrides {
1786 tool_allowlist: metadata.tool_allowlist,
1787 developer_instructions: metadata.developer_instructions,
1788 external_tools: metadata.external_tools,
1789 })
1790 .unwrap_or_default())
1791 }
1792
1793 pub async fn set_thread_selection_mode(
1794 &self,
1795 thread_id: &ThreadId,
1796 selection_mode: ModelSelectionMode,
1797 ) -> anyhow::Result<()> {
1798 let Some(store) = &self.thread_store else {
1799 return Ok(());
1800 };
1801 let Some(snapshot) = store.load_thread(thread_id).await? else {
1802 anyhow::bail!("thread not found: {thread_id}");
1803 };
1804 let Some(mut metadata) = snapshot.metadata else {
1805 return Ok(());
1806 };
1807 let concrete = selection_mode.concrete_selection();
1808 metadata.provider = Some(concrete.provider);
1809 metadata.model = Some(concrete.model);
1810 metadata.selection_mode = Some(selection_mode);
1811 metadata.updated_at = OffsetDateTime::now_utc();
1812 store.update_thread_metadata(metadata).await?;
1813 Ok(())
1814 }
1815
1816 async fn runner_session_for_thread(
1817 &self,
1818 thread_id: &ThreadId,
1819 ) -> anyhow::Result<Option<(RunnerDestination, Arc<dyn RemoteRunnerSession>)>> {
1820 let metadata = if let Some(store) = &self.thread_store {
1821 store.load_thread_metadata(thread_id).await?
1822 } else {
1823 None
1824 };
1825 let destination = metadata
1827 .as_ref()
1828 .and_then(|metadata| metadata.runner_binding.as_ref())
1829 .map(|binding| binding.destination.clone())
1830 .or(self.config.read().await.remote_runner_destination.clone());
1831 let Some(destination) = destination else {
1832 return Ok(None);
1833 };
1834 let provider = self
1835 .registry
1836 .remote_runner_providers
1837 .iter()
1838 .find(|provider| provider.id() == destination.provider_id)
1839 .cloned()
1840 .ok_or_else(|| {
1841 anyhow::anyhow!(
1842 "remote runner provider {:?} is not installed",
1843 destination.provider_id
1844 )
1845 })?;
1846 let persisted_state = metadata.and_then(|metadata| metadata.runner_state);
1847 let session = if let Some(state) = persisted_state
1848 && state.provider_id == destination.provider_id
1849 && state.destination_id == destination.id
1850 {
1851 match provider.resume_session(state).await {
1852 Ok(session) => session,
1853 Err(_) => provider.create_session(destination.clone()).await?,
1854 }
1855 } else {
1856 provider.create_session(destination.clone()).await?
1857 };
1858 Ok(Some((destination, session)))
1859 }
1860
1861 pub(crate) async fn remote_workspace_for_thread(
1867 &self,
1868 thread_id: &ThreadId,
1869 ) -> anyhow::Result<Option<Arc<RemoteWorkspace>>> {
1870 let Some(store) = &self.thread_store else {
1871 return Ok(None);
1872 };
1873 let binding = store
1874 .load_thread_metadata(thread_id)
1875 .await?
1876 .and_then(|metadata| metadata.runner_binding);
1877 let Some(binding) = binding else {
1878 return Ok(None);
1879 };
1880 let session = self
1881 .runner_session_for_thread(thread_id)
1882 .await?
1883 .map(|(_, session)| session)
1884 .ok_or_else(|| {
1885 anyhow::anyhow!("runner-bound thread {thread_id} has no runner session")
1886 })?;
1887 Ok(Some(Arc::new(RemoteWorkspace {
1888 session,
1889 root: binding.workspace,
1890 read_roots: binding.read_roots,
1891 })))
1892 }
1893
1894 async fn persist_runner_state(
1895 &self,
1896 thread_id: &ThreadId,
1897 runner: Option<&(RunnerDestination, Arc<dyn RemoteRunnerSession>)>,
1898 ) -> anyhow::Result<()> {
1899 let Some((destination, session)) = runner else {
1900 return Ok(());
1901 };
1902 let Some(store) = &self.thread_store else {
1903 return Ok(());
1904 };
1905 let Some(snapshot) = store.load_thread(thread_id).await? else {
1906 return Ok(());
1907 };
1908 let Some(mut metadata) = snapshot.metadata else {
1909 return Ok(());
1910 };
1911 metadata.runner_destination = Some(destination.clone());
1912 metadata.runner_state = Some(session.state());
1913 metadata.updated_at = OffsetDateTime::now_utc();
1914 store.update_thread_metadata(metadata).await?;
1915 Ok(())
1916 }
1917
1918 fn remote_runner_provider_by_id(
1919 &self,
1920 provider_id: &str,
1921 ) -> Option<Arc<dyn RemoteRunnerProvider>> {
1922 self.registry
1923 .remote_runner_providers
1924 .iter()
1925 .find(|provider| provider.id() == provider_id)
1926 .cloned()
1927 }
1928
1929 async fn thread_runner_session(
1932 &self,
1933 thread_id: &ThreadId,
1934 ) -> anyhow::Result<(
1935 RunnerDestination,
1936 Arc<dyn RemoteRunnerProvider>,
1937 Arc<dyn RemoteRunnerSession>,
1938 )> {
1939 let Some((destination, session)) = self.runner_session_for_thread(thread_id).await? else {
1940 anyhow::bail!("thread {thread_id} is not bound to a remote runner");
1941 };
1942 let provider = self
1943 .remote_runner_provider_by_id(&destination.provider_id)
1944 .ok_or_else(|| {
1945 anyhow::anyhow!(
1946 "remote runner provider {:?} is not installed",
1947 destination.provider_id
1948 )
1949 })?;
1950 Ok((destination, provider, session))
1951 }
1952
1953 pub async fn pause_thread_runner(
1956 &self,
1957 thread_id: &ThreadId,
1958 ) -> anyhow::Result<RunnerSessionState> {
1959 let (destination, provider, session) = self.thread_runner_session(thread_id).await?;
1960 anyhow::ensure!(
1961 provider.capabilities().pausable,
1962 "remote runner provider {:?} does not support pausing",
1963 destination.provider_id
1964 );
1965 let state = session.pause().await?;
1966 self.persist_runner_state(thread_id, Some(&(destination, session)))
1967 .await?;
1968 Ok(state)
1969 }
1970
1971 pub async fn resume_thread_runner(
1973 &self,
1974 thread_id: &ThreadId,
1975 ) -> anyhow::Result<RunnerSessionState> {
1976 let (destination, _provider, session) = self.thread_runner_session(thread_id).await?;
1977 let state = session.resume().await?;
1978 self.persist_runner_state(thread_id, Some(&(destination, session)))
1979 .await?;
1980 Ok(state)
1981 }
1982
1983 pub async fn detach_thread_runner(
1986 &self,
1987 thread_id: &ThreadId,
1988 ) -> anyhow::Result<RunnerSessionState> {
1989 let (destination, provider, session) = self.thread_runner_session(thread_id).await?;
1990 anyhow::ensure!(
1991 provider.capabilities().detachable,
1992 "remote runner provider {:?} does not support detaching",
1993 destination.provider_id
1994 );
1995 let state = session.detach().await?;
1996 if let Some(store) = &self.thread_store
1999 && let Some(snapshot) = store.load_thread(thread_id).await?
2000 && let Some(mut metadata) = snapshot.metadata
2001 {
2002 metadata.runner_destination = Some(destination);
2003 metadata.runner_state = Some(state.clone());
2004 metadata.updated_at = OffsetDateTime::now_utc();
2005 store.update_thread_metadata(metadata).await?;
2006 }
2007 Ok(state)
2008 }
2009
2010 pub async fn rejoin_thread_runner(
2014 &self,
2015 thread_id: &ThreadId,
2016 sandbox: Option<String>,
2017 ) -> anyhow::Result<RunnerSessionState> {
2018 let store = self
2019 .thread_store
2020 .as_ref()
2021 .ok_or_else(|| anyhow::anyhow!("thread store is required to rejoin a runner"))?;
2022 let metadata = store
2023 .load_thread_metadata(thread_id)
2024 .await?
2025 .ok_or_else(|| anyhow::anyhow!("thread {thread_id} has no metadata"))?;
2026 let destination = metadata
2027 .runner_binding
2028 .as_ref()
2029 .map(|binding| binding.destination.clone())
2030 .or_else(|| metadata.runner_destination.clone())
2031 .ok_or_else(|| anyhow::anyhow!("thread {thread_id} is not bound to a remote runner"))?;
2032 let mut state = metadata
2033 .runner_state
2034 .clone()
2035 .ok_or_else(|| anyhow::anyhow!("thread {thread_id} has no persisted runner state"))?;
2036 if let Some(sandbox) = sandbox
2037 && let Some(object) = state.metadata.as_object_mut()
2038 {
2039 object.insert("sandbox_name".to_string(), serde_json::Value::from(sandbox));
2040 }
2041 let provider = self
2042 .remote_runner_provider_by_id(&destination.provider_id)
2043 .ok_or_else(|| {
2044 anyhow::anyhow!(
2045 "remote runner provider {:?} is not installed",
2046 destination.provider_id
2047 )
2048 })?;
2049 let session = provider.rejoin_session(state).await?;
2050 self.persist_runner_state(thread_id, Some(&(destination, session.clone())))
2051 .await?;
2052 Ok(session.state())
2053 }
2054
2055 async fn record_thread_usage_metadata(
2056 &self,
2057 thread_id: &ThreadId,
2058 usage: &TokenUsage,
2059 ) -> anyhow::Result<()> {
2060 if usage.is_empty() {
2061 return Ok(());
2062 }
2063 let Some(store) = &self.thread_store else {
2064 return Ok(());
2065 };
2066 let Some(snapshot) = store.load_thread(thread_id).await? else {
2067 return Ok(());
2068 };
2069 let Some(mut metadata) = snapshot.metadata else {
2070 return Ok(());
2071 };
2072 metadata
2073 .usage
2074 .get_or_insert_with(ThreadUsageMetadata::default)
2075 .add_token_usage(usage);
2076 metadata.updated_at = OffsetDateTime::now_utc();
2077 store.update_thread_metadata(metadata).await?;
2078 Ok(())
2079 }
2080
2081 pub fn start_turn(
2082 self: &Arc<Self>,
2083 mut req: StartTurnRequest,
2084 ) -> BoxFuture<'_, anyhow::Result<TurnId>> {
2085 Box::pin(async move {
2086 req.workspace = validate_thread_workspace(&req.workspace)?;
2087 let cfg = self.config.read().await.clone();
2088 let provider = req
2089 .provider_override
2090 .clone()
2091 .unwrap_or_else(|| cfg.default_provider.clone());
2092 self.engine_for(&provider)?;
2093 let turn_id = uuid::Uuid::new_v4().to_string();
2094 let (abort_handle, abort_registration) = AbortHandle::new_pair();
2095 let active = ActiveTurnHandle {
2096 thread_id: req.thread_id.clone(),
2097 abort: abort_handle,
2098 steers: Arc::new(Mutex::new(Vec::new())),
2099 };
2100 self.active_turns
2101 .write()
2102 .await
2103 .insert(turn_id.clone(), active);
2104 let runtime = Arc::clone(self);
2105 let turn_req = req;
2106 let thread_id_for_task = turn_req.thread_id.clone();
2107 let turn_id_for_task = turn_id.clone();
2108 tokio::spawn(async move {
2109 let result = Abortable::new(
2110 runtime.run_turn(turn_req, turn_id_for_task.clone()),
2111 abort_registration,
2112 )
2113 .await;
2114 runtime
2122 .cancel_pending_external_tool_calls_for_turn(&turn_id_for_task)
2123 .await;
2124 let completed = matches!(&result, Ok(Ok(TurnRunOutcome::Completed)));
2125 if let Ok(Err(err)) = &result {
2126 runtime
2128 .emit(RoderEvent::TurnFailed(TurnFailed {
2129 thread_id: thread_id_for_task.clone(),
2130 turn_id: turn_id_for_task.clone(),
2131 error: err.to_string(),
2132 error_kind: None,
2133 usage: None,
2134 timestamp: OffsetDateTime::now_utc(),
2135 }))
2136 .await;
2137 }
2138 runtime.active_turns.write().await.remove(&turn_id_for_task);
2139 if completed {
2140 let _ = runtime
2141 .continue_active_goal_after_turn(thread_id_for_task)
2142 .await;
2143 }
2144 });
2145 Ok(turn_id)
2146 })
2147 }
2148
2149 pub(crate) async fn has_active_turn_for_thread(&self, thread_id: &ThreadId) -> bool {
2150 self.active_turns
2151 .read()
2152 .await
2153 .values()
2154 .any(|handle| &handle.thread_id == thread_id)
2155 }
2156
2157 pub async fn active_turn_count(&self) -> usize {
2160 self.active_turns.read().await.len()
2161 }
2162
2163 pub async fn active_turn_for_thread(&self, thread_id: &ThreadId) -> Option<TurnId> {
2164 self.active_turns
2165 .read()
2166 .await
2167 .iter()
2168 .find_map(|(turn_id, handle)| (&handle.thread_id == thread_id).then(|| turn_id.clone()))
2169 }
2170
2171 pub async fn thread_activity(&self, thread_id: &ThreadId) -> ThreadActivity {
2172 let Some(active_turn_id) = self.active_turn_for_thread(thread_id).await else {
2173 return ThreadActivity::default();
2174 };
2175
2176 let mut active_flags = Vec::new();
2177 {
2178 let pending_approvals = self.pending_tool_approvals.lock().await;
2179 if pending_approvals
2180 .values()
2181 .any(|pending| &pending.thread_id == thread_id && pending.turn_id == active_turn_id)
2182 {
2183 active_flags.push("approvalRequired".to_string());
2184 }
2185 }
2186 {
2187 let pending_inputs = self.pending_user_inputs.lock().await;
2188 if pending_inputs
2189 .values()
2190 .any(|pending| &pending.thread_id == thread_id && pending.turn_id == active_turn_id)
2191 {
2192 active_flags.push("userInputRequired".to_string());
2193 }
2194 }
2195 {
2196 let pending_external = self.pending_external_tool_calls.lock().await;
2197 if pending_external
2198 .values()
2199 .any(|pending| &pending.thread_id == thread_id && pending.turn_id == active_turn_id)
2200 {
2201 active_flags.push("externalToolPending".to_string());
2202 }
2203 }
2204 if self.pending_plan_exit().await.is_some_and(|pending| {
2205 &pending.thread_id == thread_id && pending.turn_id == active_turn_id
2206 }) {
2207 active_flags.push("planExitRequired".to_string());
2208 }
2209
2210 ThreadActivity {
2211 active_turn_id: Some(active_turn_id),
2212 active_flags,
2213 }
2214 }
2215
2216 pub async fn interrupt_turn(&self, thread_id: ThreadId, turn_id: TurnId) -> anyhow::Result<()> {
2217 if let Some(handle) = self.active_turns.write().await.remove(&turn_id) {
2218 handle.abort.abort();
2219 }
2220 self.cancel_pending_external_tool_calls_for_turn(&turn_id)
2221 .await;
2222 self.emit(RoderEvent::TurnInterrupted(TurnInterrupted {
2223 thread_id,
2224 turn_id,
2225 timestamp: OffsetDateTime::now_utc(),
2226 }))
2227 .await;
2228 Ok(())
2229 }
2230
2231 pub async fn steer_turn(
2232 &self,
2233 thread_id: ThreadId,
2234 turn_id: TurnId,
2235 message: String,
2236 images: Vec<InputImage>,
2237 ) -> anyhow::Result<()> {
2238 let message = message.trim().to_string();
2239 if message.is_empty() && images.is_empty() {
2240 return Ok(());
2241 }
2242
2243 let Some(active) = self.active_turns.read().await.get(&turn_id).cloned() else {
2244 anyhow::bail!("no active turn to steer");
2245 };
2246 active
2247 .steers
2248 .lock()
2249 .await
2250 .push(UserMessage::with_images(message.clone(), images));
2251 self.emit(RoderEvent::TurnSteered(TurnSteered {
2252 thread_id,
2253 turn_id,
2254 message,
2255 timestamp: OffsetDateTime::now_utc(),
2256 }))
2257 .await;
2258 Ok(())
2259 }
2260
2261 pub async fn tool_specs(&self) -> Vec<roder_api::tools::ToolSpec> {
2262 let cfg = self.config.read().await;
2263 let model_profile =
2264 model_profile_for_provider_model(&cfg, &cfg.default_provider, &cfg.default_model);
2265 self.filtered_tool_specs(&cfg, &cfg.default_model, model_profile.as_ref(), &[], &[])
2266 }
2267
2268 pub fn subagent_definitions(&self) -> Vec<SubagentDefinition> {
2269 self.registry
2270 .subagent_dispatchers
2271 .iter()
2272 .flat_map(|dispatcher| dispatcher.definitions())
2273 .collect()
2274 }
2275
2276 async fn run_turn(
2277 self: &Arc<Self>,
2278 req: StartTurnRequest,
2279 turn_id: TurnId,
2280 ) -> anyhow::Result<TurnRunOutcome> {
2281 let turn_started_at = OffsetDateTime::now_utc();
2282 self.emit(RoderEvent::TurnStarted(TurnStarted {
2283 thread_id: req.thread_id.clone(),
2284 turn_id: turn_id.clone(),
2285 runtime_profile: self.config.read().await.runtime_profile,
2286 timestamp: turn_started_at,
2287 }))
2288 .await;
2289 self.persist_turn_item(
2290 &req.thread_id,
2291 &turn_id,
2292 &TranscriptItem::UserMessage(UserMessage::with_images(
2293 req.message.clone(),
2294 req.images.clone(),
2295 )),
2296 )
2297 .await?;
2298
2299 let mut cfg = self.config.read().await.clone();
2300 let runtime_profile = cfg.runtime_profile;
2301 let turn_deadline = turn_deadline_for_config(&cfg);
2302 let deadline_finalization_reserve =
2303 crate::deadline_policy::finalization_reserve_seconds(cfg.turn_deadline_seconds);
2304 let selection_mode = self.selection_mode_for_thread(&req.thread_id).await?;
2305 let concrete_selection = selection_mode
2306 .as_ref()
2307 .map(ModelSelectionMode::concrete_selection);
2308 let default_provider = req
2309 .provider_override
2310 .clone()
2311 .or_else(|| {
2312 concrete_selection
2313 .as_ref()
2314 .map(|selection| selection.provider.clone())
2315 })
2316 .unwrap_or(cfg.default_provider.clone());
2317 let default_model = req
2318 .model_override
2319 .clone()
2320 .or_else(|| {
2321 concrete_selection
2322 .as_ref()
2323 .map(|selection| selection.model.clone())
2324 })
2325 .unwrap_or(cfg.default_model.clone());
2326 if let Some(reasoning) = req.reasoning_override.as_deref().or_else(|| {
2327 selection_mode
2328 .as_ref()
2329 .and_then(ModelSelectionMode::reasoning)
2330 }) {
2331 validate_reasoning_effort(&default_model, reasoning)?;
2332 cfg.reasoning = Some(reasoning.to_string());
2333 }
2334 let turn_has_concrete_model_override =
2335 req.provider_override.is_some() || req.model_override.is_some();
2336 let (turn_inference_router, turn_inference_router_profile) = match &selection_mode {
2337 Some(ModelSelectionMode::Auto {
2338 router_id, profile, ..
2339 }) if !turn_has_concrete_model_override => (
2340 RuntimeInferenceRouterConfig {
2341 enabled: true,
2342 router_id: Some(router_id.clone()),
2343 },
2344 profile.clone(),
2345 ),
2346 _ => (RuntimeInferenceRouterConfig::disabled(), None),
2347 };
2348 let mut provider = default_provider.clone();
2349 let mut model = default_model.clone();
2350 let mut model_profile = model_profile_for_provider_model(&cfg, &provider, &model);
2351 let workspace = req.workspace.clone();
2352 let mut transcript = self.transcript_for_turn(&req, &turn_id, &model).await?;
2353 let mut compacted_this_turn = transcript
2354 .iter()
2355 .any(|item| matches!(item, TranscriptItem::ContextCompaction(_)));
2356 let runner_session = self.runner_session_for_thread(&req.thread_id).await?;
2357 let effective_policy_mode = self.effective_policy_mode_for_thread(&req.thread_id).await;
2358 let agent_swarm_mode_active = self
2359 .effective_agent_swarm_mode_for_thread(&req.thread_id)
2360 .await;
2361 let thread_overrides = self.thread_turn_overrides(&req.thread_id).await?;
2362 let mut final_assistant_text = String::new();
2363 let mut final_phase_messages = Vec::<AssistantMessage>::new();
2364 let mut final_reasoning_text = String::new();
2365 let mut final_provider_metadata = None;
2366 let mut exhausted_tool_rounds = true;
2367 let mut verification_gate =
2368 VerificationGateState::new(req.message.clone(), runtime_profile);
2369 let mut speed_policy = SpeedPolicyState::default();
2370 let mut reliability = TurnReliabilityState::default();
2371 let mut turn_usage = TokenUsage::default();
2372 let mut turn_finish_reason: Option<String> = None;
2376 let mut deadline_finalization_requested = false;
2377 let mut deadline_scoreable_completion_requested = false;
2378 let mut task_ledger_completion_reminders = 0_u8;
2379 let mut task_ledger_scoreable_checkpoints = 0_u8;
2380 let mut provider_stream_retry_attempts = 0_u32;
2381 let mut routing_candidates = None;
2382 let routing_transcript_start = transcript.len().saturating_sub(1);
2383 let mut routing_escalations = 0_u32;
2384 let mut model_switch_summary_selection = None::<ModelSelection>;
2385
2386 'tool_rounds: for round_index in 0..MAX_TOOL_ROUNDS_PER_TURN {
2387 if let Some(deadline) = turn_deadline
2388 && deadline_expired(deadline)
2389 {
2390 self.fail_turn_due_to_deadline(&req.thread_id, &turn_id, deadline, &transcript)
2391 .await?;
2392 return Ok(TurnRunOutcome::Stopped);
2393 }
2394 let steers = self.drain_turn_steers(&turn_id).await;
2395 self.append_steers(&req, &turn_id, &mut transcript, steers)
2396 .await?;
2397 if runtime_profile == RuntimeProfile::Eval
2398 && let Some(remaining) = crate::deadline_policy::should_start_finalization(
2399 turn_deadline,
2400 deadline_finalization_reserve,
2401 deadline_finalization_requested || deadline_scoreable_completion_requested,
2402 )
2403 {
2404 if req.task_ledger_required
2405 && task_ledger_completion_reminders < TASK_LEDGER_COMPLETION_REMINDER_LIMIT
2406 && let Some(prompt) = task_ledger_completion_prompt(&transcript)
2407 {
2408 task_ledger_completion_reminders += 1;
2409 deadline_scoreable_completion_requested = true;
2410 let item = TranscriptItem::UserMessage(UserMessage::text(
2411 task_ledger_deadline_completion_prompt(
2412 remaining,
2413 deadline_finalization_reserve,
2414 &prompt,
2415 ),
2416 ));
2417 self.persist_turn_item(&req.thread_id, &turn_id, &item)
2418 .await?;
2419 transcript.push(item);
2420 continue 'tool_rounds;
2421 } else {
2422 self.start_deadline_finalization(
2423 &req.thread_id,
2424 &turn_id,
2425 &mut transcript,
2426 remaining,
2427 )
2428 .await?;
2429 deadline_finalization_requested = true;
2430 }
2431 }
2432 if runtime_profile == RuntimeProfile::Eval
2433 && req.task_ledger_required
2434 && !deadline_finalization_requested
2435 && task_ledger_scoreable_checkpoints < TASK_LEDGER_SCOREABLE_CHECKPOINT_LIMIT
2436 && let Some(remaining) = deadline_remaining_seconds(turn_deadline)
2437 && remaining <= TASK_LEDGER_SCOREABLE_CHECKPOINT_SECONDS
2438 && remaining > deadline_finalization_reserve
2439 && let Some(prompt) = task_ledger_completion_prompt(&transcript)
2440 {
2441 task_ledger_scoreable_checkpoints += 1;
2442 let item = TranscriptItem::UserMessage(UserMessage::text(
2443 task_ledger_scoreable_checkpoint_prompt(remaining, &prompt),
2444 ));
2445 self.persist_turn_item(&req.thread_id, &turn_id, &item)
2446 .await?;
2447 transcript.push(item);
2448 continue 'tool_rounds;
2449 }
2450 if turn_inference_router.is_active() && routing_candidates.is_none() {
2451 routing_candidates =
2452 Some(collect_inference_routing_candidates(&self.registry).await);
2453 }
2454 let routing_tools_model = model.clone();
2455 let routing_tools = self.filtered_tool_specs(
2456 &cfg,
2457 &model,
2458 model_profile.as_ref(),
2459 &thread_overrides.tool_allowlist,
2460 &thread_overrides.external_tools,
2461 );
2462 let prior_failures =
2463 transcript_failure_count_since(&transcript, routing_transcript_start)
2464 .max(reliability.tool_failure_count())
2465 .saturating_add(provider_stream_retry_attempts);
2466 let routing_selection = route_inference_selection(
2467 &self.registry,
2468 &turn_inference_router,
2469 InferenceRoutingRequest {
2470 thread_id: &req.thread_id,
2471 turn_id: &turn_id,
2472 round_index: round_index as u32,
2473 runtime_profile,
2474 phase: speed_policy.phase(),
2475 profile: turn_inference_router_profile.as_deref(),
2476 default_selection: ModelSelection {
2477 provider: default_provider.clone(),
2478 model: default_model.clone(),
2479 },
2480 transcript: &transcript,
2481 tools: &routing_tools,
2482 candidates: routing_candidates.as_deref(),
2483 prior_failures,
2484 prior_escalations: routing_escalations,
2485 },
2486 )
2487 .await;
2488 if let Some(decision) = routing_selection.decision.clone() {
2489 if matches!(decision.outcome, InferenceRoutingOutcome::Escalated) {
2490 routing_escalations = routing_escalations.saturating_add(1);
2491 }
2492 self.emit(RoderEvent::InferenceRoutingDecision(
2493 InferenceRoutingDecisionEvent {
2494 thread_id: req.thread_id.clone(),
2495 turn_id: turn_id.clone(),
2496 round_index: round_index as u32,
2497 default_selection: ModelSelection {
2498 provider: default_provider.clone(),
2499 model: default_model.clone(),
2500 },
2501 selected_selection: routing_selection.selection.clone(),
2502 decision,
2503 timestamp: OffsetDateTime::now_utc(),
2504 },
2505 ))
2506 .await;
2507 }
2508 provider = routing_selection.selection.provider.clone();
2509 model = routing_selection.selection.model.clone();
2510 let engine = self.engine_for(&provider)?;
2511 let capabilities = engine.capabilities();
2512 model_profile = model_profile_for_provider_model(&cfg, &provider, &model);
2513 let tools = if capabilities.tool_calls {
2514 if model == routing_tools_model {
2515 routing_tools.clone()
2516 } else {
2517 self.filtered_tool_specs(
2518 &cfg,
2519 &model,
2520 model_profile.as_ref(),
2521 &thread_overrides.tool_allowlist,
2522 &thread_overrides.external_tools,
2523 )
2524 }
2525 } else {
2526 Vec::new()
2527 };
2528 let parallel_tool_calls = parallel_tool_calls_for_model(&cfg, &model);
2529 let tool_choice = if tools.is_empty() {
2530 ToolChoice::None
2531 } else {
2532 ToolChoice::Auto
2533 };
2534 let summary_selection = ModelSelection {
2535 provider: provider.clone(),
2536 model: model.clone(),
2537 };
2538 if model_switch_summary_selection.as_ref() != Some(&summary_selection) {
2539 if let Some(summary) = model_switch_summary(
2540 &transcript,
2541 model_profile.as_ref(),
2542 &provider,
2543 &model,
2544 &tools,
2545 ) {
2546 let item = TranscriptItem::UserMessage(UserMessage::text(summary));
2547 self.persist_turn_item(&req.thread_id, &turn_id, &item)
2548 .await?;
2549 transcript.push(item);
2550 }
2551 model_switch_summary_selection = Some(summary_selection);
2552 }
2553
2554 if !capabilities.image_input && transcript_has_images(&transcript) {
2555 self.fail_turn_with_error(
2556 &req.thread_id,
2557 &turn_id,
2558 format!("provider {provider} does not support image input"),
2559 )
2560 .await?;
2561 return Ok(TurnRunOutcome::Stopped);
2562 }
2563 transcript = self
2564 .compact_transcript_if_needed(
2565 &req.thread_id,
2566 &turn_id,
2567 &provider,
2568 &model,
2569 transcript,
2570 self.compaction_options_for_turn(&req.thread_id, !compacted_this_turn),
2571 )
2572 .await?;
2573 compacted_this_turn = compacted_this_turn
2574 || transcript
2575 .iter()
2576 .any(|item| matches!(item, TranscriptItem::ContextCompaction(_)));
2577
2578 let speed_policy_decision =
2579 speed_policy.decision(runtime_profile, &model, &cfg.speed_policy);
2580 let request_reasoning = reasoning_from_decision(
2581 speed_policy_decision.as_ref(),
2582 routing_selection
2583 .reasoning
2584 .clone()
2585 .unwrap_or_else(|| reasoning_for_model(&cfg, &model)),
2586 );
2587 if let Some(limit) = reliability.record_model_call(
2588 &cfg.reliability,
2589 runtime_profile == RuntimeProfile::Interactive,
2590 ) {
2591 self.fail_turn_due_to_reliability_limit(
2592 &req.thread_id,
2593 &turn_id,
2594 &provider,
2595 &model,
2596 limit,
2597 &transcript,
2598 )
2599 .await?;
2600 return Ok(TurnRunOutcome::Stopped);
2601 }
2602 self.emit(RoderEvent::InferenceStarted(InferenceStarted {
2603 thread_id: req.thread_id.clone(),
2604 turn_id: turn_id.clone(),
2605 engine_id: engine.id(),
2606 model: ModelSelection {
2607 provider: provider.clone(),
2608 model: model.clone(),
2609 },
2610 reasoning: request_reasoning.clone(),
2611 speed_policy: speed_policy_decision.clone(),
2612 deadline_remaining_seconds: deadline_remaining_seconds(turn_deadline),
2613 timestamp: OffsetDateTime::now_utc(),
2614 }))
2615 .await;
2616
2617 let mut instructions = req.instructions.clone();
2618 if let Some(extra) = &thread_overrides.developer_instructions {
2619 instructions = apply_thread_developer_instructions(instructions, extra);
2620 }
2621 if let Some(context) = req.developer_context.as_deref() {
2622 instructions = apply_turn_developer_context(instructions, context);
2623 }
2624 let mut instructions = apply_runtime_profile(instructions, runtime_profile);
2625 if let Some(profile) = &model_profile {
2626 instructions = apply_model_instruction_overlay(instructions, profile);
2627 }
2628 if req.task_ledger_required
2629 && runtime_profile == RuntimeProfile::Eval
2630 && !transcript_has_task_ledger(&transcript)
2631 {
2632 instructions = apply_task_ledger_required(instructions);
2633 }
2634 if effective_policy_mode == PolicyMode::Plan {
2635 instructions = apply_plan_mode(instructions);
2636 }
2637 if agent_swarm_mode_active {
2638 instructions = apply_agent_swarm_mode(instructions);
2639 }
2640 instructions = self
2641 .goals
2642 .apply_goal_instructions(&req.thread_id, instructions)
2643 .await?;
2644 let mut request_metadata = serde_json::json!({});
2645 if let Some(decision) = &speed_policy_decision {
2646 request_metadata["speedPolicy"] = serde_json::json!(decision);
2647 }
2648 if let Some(decision) = routing_selection.decision.as_ref() {
2649 request_metadata["inferenceRouting"] = serde_json::json!(decision);
2650 }
2651 if let Some(remaining) = deadline_remaining_seconds(turn_deadline) {
2652 request_metadata["deadlineRemainingSeconds"] = serde_json::json!(remaining);
2653 }
2654 if let Some(profile) = &model_profile {
2655 request_metadata["modelProfile"] = serde_json::json!({
2656 "model": profile.model,
2657 "providerFamily": profile.provider_family,
2658 "editTool": profile.edit_tool,
2659 "schemaPolicy": profile.schema_policy,
2660 "instructionOverlay": profile.instruction_overlay,
2661 "parallelToolCalls": profile.parallel_tool_calls,
2662 "autoCompactTokenLimit": profile.auto_compact_token_limit,
2663 });
2664 }
2665 let task_ledger_required_this_round = req.task_ledger_required
2666 && runtime_profile == RuntimeProfile::Eval
2667 && !deadline_finalization_requested
2668 && !transcript_has_task_ledger(&transcript);
2669 let task_ledger_tools = (capabilities.tool_calls && task_ledger_required_this_round)
2670 .then(|| self.task_ledger_tool_specs(model_profile.as_ref()))
2671 .filter(|tools| !tools.is_empty());
2672 let request_tools = if deadline_finalization_requested {
2673 Vec::new()
2674 } else if let Some(ledger_tools) = &task_ledger_tools {
2675 ledger_tools.clone()
2676 } else {
2677 tools.clone()
2678 };
2679 let request_tool_choice = if deadline_finalization_requested {
2680 ToolChoice::None
2681 } else if task_ledger_tools.is_some() {
2682 ToolChoice::Specific(TASK_LEDGER_TOOL_NAME.to_string())
2683 } else {
2684 tool_choice.clone()
2685 };
2686 if deadline_finalization_requested {
2687 request_metadata["deadlineFinalization"] = serde_json::json!({
2688 "reserveSeconds": deadline_finalization_reserve,
2689 "remainingSeconds": deadline_remaining_seconds(turn_deadline),
2690 });
2691 } else if deadline_scoreable_completion_requested {
2692 request_metadata["deadlineScoreableCompletion"] = serde_json::json!({
2693 "reserveSeconds": deadline_finalization_reserve,
2694 "remainingSeconds": deadline_remaining_seconds(turn_deadline),
2695 });
2696 }
2697 let request = AgentInferenceRequest {
2698 model: ModelSelection {
2699 provider: provider.clone(),
2700 model: model.clone(),
2701 },
2702 instructions,
2703 transcript: transcript.clone(),
2704 tools: request_tools,
2705 tool_choice: request_tool_choice,
2706 reasoning: request_reasoning,
2707 output: OutputConfig::default(),
2708 runtime: RuntimeHints {
2709 auto_compact_token_limit: server_side_compaction_threshold(&cfg, &model),
2710 profile: runtime_profile,
2711 parallel_tool_calls: Some(parallel_tool_calls),
2712 hosted_web_search: cfg.hosted_web_search.clone(),
2713 tool_search: tool_search_for_provider_model(&cfg, &provider, &model),
2714 speed_policy: speed_policy_decision,
2715 reliability: Some(cfg.reliability.clone().into()),
2716 deadline_remaining_seconds: deadline_remaining_seconds(turn_deadline),
2717 ..RuntimeHints::default()
2718 },
2719 metadata: request_metadata,
2720 };
2721
2722 let ctx = InferenceTurnContext {
2723 thread_id: &req.thread_id,
2724 turn_id: &turn_id,
2725 tool_executor: Some(std::sync::Arc::new(
2726 crate::tool_execution::RuntimeTurnToolExecutor {
2727 runtime: Arc::clone(self),
2728 thread_id: req.thread_id.clone(),
2729 turn_id: turn_id.clone(),
2730 workspace: Some(workspace.clone()),
2731 deadline: turn_deadline,
2732 },
2733 )),
2734 };
2735 let stream_future = engine.stream_turn(ctx, request);
2736 let mut stream = if let Some((deadline, timeout_action)) = inference_timeout_deadline(
2737 turn_deadline,
2738 runtime_profile,
2739 req.task_ledger_required,
2740 deadline_finalization_reserve,
2741 deadline_finalization_requested || deadline_scoreable_completion_requested,
2742 task_ledger_scoreable_checkpoints,
2743 &transcript,
2744 ) {
2745 match tokio::time::timeout_at(deadline_instant(deadline), stream_future).await {
2746 Ok(stream) => stream?,
2747 Err(_) => {
2748 if runtime_profile == RuntimeProfile::Eval
2749 && !deadline_finalization_requested
2750 {
2751 let remaining = deadline_remaining_seconds(turn_deadline).unwrap_or(0);
2752 if timeout_action == InferenceTimeoutAction::ScoreableCheckpoint
2753 && task_ledger_scoreable_checkpoints
2754 < TASK_LEDGER_SCOREABLE_CHECKPOINT_LIMIT
2755 && let Some(prompt) = task_ledger_completion_prompt(&transcript)
2756 {
2757 task_ledger_scoreable_checkpoints += 1;
2758 let item = TranscriptItem::UserMessage(UserMessage::text(
2759 task_ledger_scoreable_checkpoint_prompt(remaining, &prompt),
2760 ));
2761 self.persist_turn_item(&req.thread_id, &turn_id, &item)
2762 .await?;
2763 transcript.push(item);
2764 continue 'tool_rounds;
2765 }
2766 self.start_deadline_finalization(
2767 &req.thread_id,
2768 &turn_id,
2769 &mut transcript,
2770 remaining,
2771 )
2772 .await?;
2773 deadline_finalization_requested = true;
2774 continue 'tool_rounds;
2775 }
2776 self.fail_turn_due_to_deadline(
2777 &req.thread_id,
2778 &turn_id,
2779 deadline,
2780 &transcript,
2781 )
2782 .await?;
2783 return Ok(TurnRunOutcome::Stopped);
2784 }
2785 }
2786 } else {
2787 stream_future.await?
2788 };
2789 let mut assistant_text = String::new();
2790 let mut phase_messages = Vec::<AssistantMessage>::new();
2791 let mut reasoning_text = String::new();
2792 let mut tool_calls = Vec::new();
2793 let mut provider_metadata = None;
2794
2795 loop {
2796 let next = if let Some((deadline, timeout_action)) = inference_timeout_deadline(
2797 turn_deadline,
2798 runtime_profile,
2799 req.task_ledger_required,
2800 deadline_finalization_reserve,
2801 deadline_finalization_requested || deadline_scoreable_completion_requested,
2802 task_ledger_scoreable_checkpoints,
2803 &transcript,
2804 ) {
2805 match tokio::time::timeout_at(deadline_instant(deadline), stream.next()).await {
2806 Ok(next) => next,
2807 Err(_) => {
2808 if runtime_profile == RuntimeProfile::Eval
2809 && !deadline_finalization_requested
2810 {
2811 let remaining =
2812 deadline_remaining_seconds(turn_deadline).unwrap_or(0);
2813 if timeout_action == InferenceTimeoutAction::ScoreableCheckpoint
2814 && task_ledger_scoreable_checkpoints
2815 < TASK_LEDGER_SCOREABLE_CHECKPOINT_LIMIT
2816 && let Some(prompt) = task_ledger_completion_prompt(&transcript)
2817 {
2818 task_ledger_scoreable_checkpoints += 1;
2819 let item = TranscriptItem::UserMessage(UserMessage::text(
2820 task_ledger_scoreable_checkpoint_prompt(remaining, &prompt),
2821 ));
2822 self.persist_turn_item(&req.thread_id, &turn_id, &item)
2823 .await?;
2824 transcript.push(item);
2825 continue 'tool_rounds;
2826 }
2827 self.start_deadline_finalization(
2828 &req.thread_id,
2829 &turn_id,
2830 &mut transcript,
2831 remaining,
2832 )
2833 .await?;
2834 deadline_finalization_requested = true;
2835 continue 'tool_rounds;
2836 }
2837 self.fail_turn_due_to_deadline(
2838 &req.thread_id,
2839 &turn_id,
2840 deadline,
2841 &transcript,
2842 )
2843 .await?;
2844 return Ok(TurnRunOutcome::Stopped);
2845 }
2846 }
2847 } else {
2848 stream.next().await
2849 };
2850 let Some(res) = next else {
2851 break;
2852 };
2853 let event = match res {
2854 Ok(event) => event,
2855 Err(err) => {
2856 let error = err.to_string();
2857 if runtime_profile == RuntimeProfile::Eval
2858 && !deadline_finalization_requested
2859 && let Some(cause) = provider_stream_retry_cause(&error)
2860 {
2861 let retry_attempt = provider_stream_retry_attempts.saturating_add(1);
2862 let policy: ReliabilityRequestPolicy = cfg.reliability.clone().into();
2863 if retry_attempt < policy.provider_retry_max_attempts {
2864 provider_stream_retry_attempts = retry_attempt;
2865 let delay_ms = provider_retry_delay_ms(&policy, retry_attempt);
2866 self.emit(RoderEvent::ReliabilityRetryRecorded(
2867 ReliabilityRetryRecorded {
2868 context: ReliabilityContext {
2869 thread_id: req.thread_id.clone(),
2870 turn_id: turn_id.clone(),
2871 provider: Some(provider.clone()),
2872 model: Some(model.clone()),
2873 ..ReliabilityContext::default()
2874 },
2875 error_class: ReliabilityErrorClass::ProviderError,
2876 decision: ReliabilityRetryDecision::Retry,
2877 attempt: retry_attempt,
2878 max_attempts: policy.provider_retry_max_attempts,
2879 delay_ms: Some(delay_ms),
2880 details: ReliabilityDetails::redacted(format!(
2881 "{cause}: {error}"
2882 )),
2883 timestamp: OffsetDateTime::now_utc(),
2884 },
2885 ))
2886 .await;
2887 if delay_ms > 0 {
2888 tokio::time::sleep(std::time::Duration::from_millis(delay_ms))
2889 .await;
2890 }
2891 continue 'tool_rounds;
2892 }
2893 }
2894 self.emit(RoderEvent::TurnFailed(TurnFailed {
2895 thread_id: req.thread_id.clone(),
2896 turn_id: turn_id.clone(),
2897 error,
2898 error_kind: None,
2899 usage: None,
2900 timestamp: OffsetDateTime::now_utc(),
2901 }))
2902 .await;
2903 self.complete_team_member_turn(
2904 &req.thread_id,
2905 &turn_id,
2906 TeamMemberStatus::Failed,
2907 )
2908 .await?;
2909 return Err(err);
2910 }
2911 };
2912
2913 let inference_timestamp = OffsetDateTime::now_utc();
2914 self.emit(RoderEvent::InferenceEventReceived(InferenceEventReceived {
2915 thread_id: req.thread_id.clone(),
2916 turn_id: turn_id.clone(),
2917 event: event.clone(),
2918 timestamp: inference_timestamp,
2919 }))
2920 .await;
2921
2922 match event {
2923 InferenceEvent::MessageDelta(delta) => {
2924 if let Some((team_id, member)) =
2925 self.teams.member_for_thread(&req.thread_id).await
2926 {
2927 self.emit(RoderEvent::TeamMemberMessageDelta(TeamMemberMessageDelta {
2928 team_id,
2929 member_id: member.id,
2930 member_thread_id: req.thread_id.clone(),
2931 turn_id: turn_id.clone(),
2932 delta: delta.text.clone(),
2933 timestamp: OffsetDateTime::now_utc(),
2934 }))
2935 .await;
2936 }
2937 if is_final_answer_phase(delta.phase.as_deref()) {
2938 assistant_text.push_str(&delta.text);
2939 } else if let Some(last) = phase_messages.last_mut()
2940 && last.phase == delta.phase
2941 {
2942 last.text.push_str(&delta.text);
2943 } else {
2944 phase_messages.push(AssistantMessage {
2945 text: delta.text,
2946 phase: delta.phase,
2947 });
2948 }
2949 }
2950 InferenceEvent::ReasoningDelta(delta) => reasoning_text.push_str(&delta.text),
2951 InferenceEvent::ToolCallCompleted(call) => tool_calls.push(call),
2952 InferenceEvent::Failed(failure) => {
2953 speed_policy.record_failure();
2954 self.persist_turn_item(
2955 &req.thread_id,
2956 &turn_id,
2957 &TranscriptItem::Error(ErrorRecord {
2958 message: failure.message.clone(),
2959 }),
2960 )
2961 .await?;
2962 self.emit(RoderEvent::TurnFailed(TurnFailed {
2963 thread_id: req.thread_id.clone(),
2964 turn_id: turn_id.clone(),
2965 error: failure.message,
2966 error_kind: None,
2967 usage: None,
2968 timestamp: OffsetDateTime::now_utc(),
2969 }))
2970 .await;
2971 self.complete_team_member_turn(
2972 &req.thread_id,
2973 &turn_id,
2974 TeamMemberStatus::Failed,
2975 )
2976 .await?;
2977 return Ok(TurnRunOutcome::Stopped);
2978 }
2979 InferenceEvent::Usage(usage) => {
2980 turn_usage.add_assign(&usage);
2981 }
2982 InferenceEvent::Completed(metadata) => {
2983 turn_finish_reason = metadata
2984 .stop_reason
2985 .as_deref()
2986 .map(finish_reason_from_stop_reason);
2987 }
2988 InferenceEvent::Compaction(_)
2989 | InferenceEvent::HostedToolCallStarted(_)
2990 | InferenceEvent::HostedToolCallCompleted(_)
2991 | InferenceEvent::ToolCallStarted(_)
2992 | InferenceEvent::ToolCallDelta(_) => {}
2993 InferenceEvent::ProviderMetadata(metadata) => {
2994 provider_metadata = Some(metadata);
2995 }
2996 }
2997 }
2998
2999 speed_policy.record_model_output(
3000 !assistant_text.is_empty() || !phase_messages.is_empty(),
3001 tool_calls.len(),
3002 );
3003 if tool_calls.is_empty() {
3004 let steers = self.drain_turn_steers(&turn_id).await;
3005 if !steers.is_empty() {
3006 for message in phase_messages {
3007 let item = TranscriptItem::AssistantMessage(message);
3008 self.persist_turn_item(&req.thread_id, &turn_id, &item)
3009 .await?;
3010 transcript.push(item);
3011 self.persist_model_profile_segment(
3012 &req.thread_id,
3013 &turn_id,
3014 model_profile.as_ref(),
3015 &provider,
3016 &model,
3017 "assistant",
3018 )
3019 .await?;
3020 }
3021 if !assistant_text.is_empty() {
3022 let assistant = TranscriptItem::AssistantMessage(AssistantMessage {
3023 text: assistant_text,
3024 phase: Some(FINAL_ANSWER_PHASE.to_string()),
3025 });
3026 self.persist_turn_item(&req.thread_id, &turn_id, &assistant)
3027 .await?;
3028 transcript.push(assistant);
3029 self.persist_model_profile_segment(
3030 &req.thread_id,
3031 &turn_id,
3032 model_profile.as_ref(),
3033 &provider,
3034 &model,
3035 "assistant",
3036 )
3037 .await?;
3038 }
3039 if let Some(metadata) = provider_metadata {
3040 let item = TranscriptItem::ProviderMetadata(metadata);
3041 self.persist_turn_item(&req.thread_id, &turn_id, &item)
3042 .await?;
3043 transcript.push(item);
3044 }
3045 self.append_steers(&req, &turn_id, &mut transcript, steers)
3046 .await?;
3047 continue;
3048 }
3049 if !deadline_finalization_requested
3050 && req.task_ledger_required
3051 && runtime_profile == RuntimeProfile::Eval
3052 && task_ledger_completion_reminders < TASK_LEDGER_COMPLETION_REMINDER_LIMIT
3053 && (!assistant_text.trim().is_empty() || !phase_messages.is_empty())
3054 && let Some(prompt) = task_ledger_completion_prompt(&transcript)
3055 {
3056 task_ledger_completion_reminders += 1;
3057 let item = TranscriptItem::UserMessage(UserMessage::text(prompt));
3058 self.persist_turn_item(&req.thread_id, &turn_id, &item)
3059 .await?;
3060 transcript.push(item);
3061 continue;
3062 }
3063 if !deadline_finalization_requested
3064 && let Some(prompt) = verification_gate.blocking_prompt()
3065 {
3066 speed_policy.record_verification_required();
3067 self.emit(RoderEvent::VerificationRequired(VerificationRequired {
3068 thread_id: req.thread_id.clone(),
3069 turn_id: turn_id.clone(),
3070 reason: verification_gate.reason(),
3071 changed_files: verification_gate.changed_files(),
3072 tool_evidence: verification_gate.tool_evidence.clone(),
3073 tests_run: verification_gate.tests_run.clone(),
3074 open_gaps: verification_gate.open_gaps.clone(),
3075 timestamp: OffsetDateTime::now_utc(),
3076 }))
3077 .await;
3078 let item = TranscriptItem::UserMessage(UserMessage::text(prompt));
3079 self.persist_turn_item(&req.thread_id, &turn_id, &item)
3080 .await?;
3081 transcript.push(item);
3082 continue;
3083 }
3084 if deadline_finalization_requested
3085 && assistant_text.trim().is_empty()
3086 && phase_messages.is_empty()
3087 {
3088 assistant_text = format!(
3089 "Deadline finalization completed without model text. {}",
3090 turn_partial_result(&transcript)
3091 );
3092 }
3093 final_phase_messages = phase_messages;
3094 final_assistant_text = assistant_text;
3095 final_reasoning_text = reasoning_text;
3096 final_provider_metadata = provider_metadata;
3097 exhausted_tool_rounds = false;
3098 break;
3099 }
3100
3101 for message in phase_messages {
3102 let item = TranscriptItem::AssistantMessage(message);
3103 self.persist_turn_item(&req.thread_id, &turn_id, &item)
3104 .await?;
3105 transcript.push(item);
3106 self.persist_model_profile_segment(
3107 &req.thread_id,
3108 &turn_id,
3109 model_profile.as_ref(),
3110 &provider,
3111 &model,
3112 "assistant",
3113 )
3114 .await?;
3115 }
3116 if !assistant_text.is_empty() {
3117 transcript.push(TranscriptItem::AssistantMessage(AssistantMessage {
3118 text: assistant_text,
3119 phase: Some(FINAL_ANSWER_PHASE.to_string()),
3120 }));
3121 self.persist_model_profile_segment(
3122 &req.thread_id,
3123 &turn_id,
3124 model_profile.as_ref(),
3125 &provider,
3126 &model,
3127 "assistant",
3128 )
3129 .await?;
3130 }
3131 if let Some(metadata) = provider_metadata {
3132 let item = TranscriptItem::ProviderMetadata(metadata);
3133 self.persist_turn_item(&req.thread_id, &turn_id, &item)
3134 .await?;
3135 transcript.push(item);
3136 }
3137 for call in &tool_calls {
3138 let tool_item = TranscriptItem::ToolCall(ToolCallRecord {
3139 id: call.id.clone(),
3140 name: call.name.clone(),
3141 arguments: call.arguments.clone(),
3142 });
3143 self.persist_turn_item(&req.thread_id, &turn_id, &tool_item)
3144 .await?;
3145 transcript.push(tool_item);
3146 self.persist_model_profile_segment(
3147 &req.thread_id,
3148 &turn_id,
3149 model_profile.as_ref(),
3150 &provider,
3151 &model,
3152 "tool_call",
3153 )
3154 .await?;
3155 }
3156 if let Some(deadline) = turn_deadline
3157 && deadline_expired(deadline)
3158 {
3159 self.fail_turn_due_to_deadline(&req.thread_id, &turn_id, deadline, &transcript)
3160 .await?;
3161 return Ok(TurnRunOutcome::Stopped);
3162 }
3163 let results = self
3164 .route_tool_calls(
3165 &req.thread_id,
3166 &turn_id,
3167 tool_calls,
3168 parallel_tool_calls,
3169 Some(workspace.as_str()),
3170 turn_deadline,
3171 )
3172 .await?;
3173 if let Some(limit) = reliability.record_tool_results(
3174 &cfg.reliability,
3175 &results,
3176 runtime_profile == RuntimeProfile::Interactive,
3177 ) {
3178 self.fail_turn_due_to_reliability_limit(
3179 &req.thread_id,
3180 &turn_id,
3181 &provider,
3182 &model,
3183 limit,
3184 &transcript,
3185 )
3186 .await?;
3187 return Ok(TurnRunOutcome::Stopped);
3188 }
3189 for result in results {
3190 verification_gate.record_tool_result(&result);
3191 transcript.push(TranscriptItem::ToolResult(result));
3192 self.persist_model_profile_segment(
3193 &req.thread_id,
3194 &turn_id,
3195 model_profile.as_ref(),
3196 &provider,
3197 &model,
3198 "tool_result",
3199 )
3200 .await?;
3201 }
3202 transcript = self
3203 .compact_transcript_if_needed(
3204 &req.thread_id,
3205 &turn_id,
3206 &provider,
3207 &model,
3208 transcript,
3209 self.compaction_options_for_turn(&req.thread_id, !compacted_this_turn),
3210 )
3211 .await?;
3212 compacted_this_turn = compacted_this_turn
3213 || transcript
3214 .iter()
3215 .any(|item| matches!(item, TranscriptItem::ContextCompaction(_)));
3216 }
3217
3218 if exhausted_tool_rounds {
3219 let message =
3220 format!("tool call limit reached after {MAX_TOOL_ROUNDS_PER_TURN} rounds");
3221 self.persist_turn_item(
3222 &req.thread_id,
3223 &turn_id,
3224 &TranscriptItem::Error(ErrorRecord {
3225 message: message.clone(),
3226 }),
3227 )
3228 .await?;
3229 self.emit(RoderEvent::TurnFailed(TurnFailed {
3230 thread_id: req.thread_id.clone(),
3231 turn_id: turn_id.clone(),
3232 error: message,
3233 error_kind: None,
3234 usage: None,
3235 timestamp: OffsetDateTime::now_utc(),
3236 }))
3237 .await;
3238 self.complete_team_member_turn(&req.thread_id, &turn_id, TeamMemberStatus::Failed)
3239 .await?;
3240 return Ok(TurnRunOutcome::Stopped);
3241 }
3242
3243 if !final_reasoning_text.is_empty() {
3244 self.persist_turn_item(
3245 &req.thread_id,
3246 &turn_id,
3247 &TranscriptItem::ReasoningSummary(ReasoningSummary {
3248 text: final_reasoning_text,
3249 }),
3250 )
3251 .await?;
3252 }
3253 for message in final_phase_messages {
3254 self.persist_turn_item(
3255 &req.thread_id,
3256 &turn_id,
3257 &TranscriptItem::AssistantMessage(message),
3258 )
3259 .await?;
3260 self.persist_model_profile_segment(
3261 &req.thread_id,
3262 &turn_id,
3263 model_profile.as_ref(),
3264 &provider,
3265 &model,
3266 "assistant",
3267 )
3268 .await?;
3269 }
3270 if !final_assistant_text.is_empty() {
3271 self.persist_turn_item(
3272 &req.thread_id,
3273 &turn_id,
3274 &TranscriptItem::AssistantMessage(AssistantMessage {
3275 text: final_assistant_text,
3276 phase: Some(FINAL_ANSWER_PHASE.to_string()),
3277 }),
3278 )
3279 .await?;
3280 self.persist_model_profile_segment(
3281 &req.thread_id,
3282 &turn_id,
3283 model_profile.as_ref(),
3284 &provider,
3285 &model,
3286 "assistant",
3287 )
3288 .await?;
3289 }
3290 if let Some(metadata) = final_provider_metadata {
3291 self.persist_turn_item(
3292 &req.thread_id,
3293 &turn_id,
3294 &TranscriptItem::ProviderMetadata(metadata),
3295 )
3296 .await?;
3297 }
3298
3299 let turn_usage_tokens = turn_usage.total_tokens as i64;
3300 let completed_usage = (!turn_usage.is_empty()).then_some(turn_usage.clone());
3301 self.record_thread_usage_metadata(&req.thread_id, &turn_usage)
3302 .await?;
3303 self.goals
3304 .account_turn_usage(
3305 &req.thread_id,
3306 turn_usage_tokens,
3307 OffsetDateTime::now_utc() - turn_started_at,
3308 )
3309 .await?;
3310 self.emit(RoderEvent::TurnCompleted(TurnCompleted {
3311 thread_id: req.thread_id.clone(),
3312 turn_id: turn_id.clone(),
3313 usage: completed_usage,
3314 finish_reason: turn_finish_reason,
3315 timestamp: OffsetDateTime::now_utc(),
3316 }))
3317 .await;
3318 self.complete_team_member_turn(&req.thread_id, &turn_id, TeamMemberStatus::Completed)
3319 .await?;
3320 self.persist_runner_state(&req.thread_id, runner_session.as_ref())
3321 .await?;
3322 Ok(TurnRunOutcome::Completed)
3323 }
3324
3325 async fn drain_turn_steers(&self, turn_id: &TurnId) -> Vec<UserMessage> {
3326 let Some(active) = self.active_turns.read().await.get(turn_id).cloned() else {
3327 return Vec::new();
3328 };
3329 let mut steers = active.steers.lock().await;
3330 std::mem::take(&mut *steers)
3331 }
3332
3333 async fn route_tool_calls(
3334 self: &Arc<Self>,
3335 thread_id: &ThreadId,
3336 turn_id: &TurnId,
3337 calls: Vec<ToolCallCompleted>,
3338 parallel: bool,
3339 workspace: Option<&str>,
3340 deadline: Option<OffsetDateTime>,
3341 ) -> anyhow::Result<Vec<ToolResultRecord>> {
3342 if let Some(violation) = roder_api::subagents::agent_swarm_batch_violation(
3348 calls.iter().map(|call| call.name.as_str()),
3349 ) {
3350 let message = violation.deny_message();
3351 return Ok(calls
3352 .into_iter()
3353 .map(|call| ToolResultRecord {
3354 id: call.id,
3355 name: Some(call.name),
3356 result: message.clone(),
3357 display_payload: None,
3358 is_error: true,
3359 })
3360 .collect());
3361 }
3362 let swarm_call = (calls.len() == 1
3366 && calls[0].name == roder_api::subagents::AGENT_SWARM_TOOL_NAME)
3367 .then(|| (calls[0].id.clone(), calls[0].arguments.clone()));
3368 if let Some((tool_id, args)) = &swarm_call {
3369 self.emit(RoderEvent::AgentSwarmStarted(
3370 roder_api::subagents::AgentSwarmStarted {
3371 thread_id: thread_id.clone(),
3372 turn_id: turn_id.clone(),
3373 tool_id: tool_id.clone(),
3374 child_count: agent_swarm_child_count(args),
3375 timestamp: OffsetDateTime::now_utc(),
3376 },
3377 ))
3378 .await;
3379 }
3380
3381 let force_sequential = calls
3382 .iter()
3383 .any(|call| crate::agent_control_tools::is_agent_control_tool(&call.name));
3384 let results = if parallel && !force_sequential {
3385 try_join_all(
3386 calls.into_iter().map(|call| {
3387 self.route_tool_call(thread_id, turn_id, call, workspace, deadline)
3388 }),
3389 )
3390 .await
3391 } else {
3392 let mut results = Vec::with_capacity(calls.len());
3393 for call in calls {
3394 results.push(
3395 self.route_tool_call(thread_id, turn_id, call, workspace, deadline)
3396 .await?,
3397 );
3398 }
3399 Ok(results)
3400 }?;
3401
3402 if let Some((tool_id, _)) = &swarm_call
3403 && let Some(result) = results.iter().find(|result| &result.id == tool_id)
3404 && let Some((completed, failed, aborted)) = parse_swarm_counts(&result.result)
3405 {
3406 self.emit(RoderEvent::AgentSwarmCompleted(
3407 roder_api::subagents::AgentSwarmCompleted {
3408 thread_id: thread_id.clone(),
3409 turn_id: turn_id.clone(),
3410 tool_id: tool_id.clone(),
3411 completed,
3412 failed,
3413 aborted,
3414 timestamp: OffsetDateTime::now_utc(),
3415 },
3416 ))
3417 .await;
3418 }
3419
3420 Ok(results)
3421 }
3422
3423 async fn fail_turn_with_error(
3424 &self,
3425 thread_id: &ThreadId,
3426 turn_id: &TurnId,
3427 message: String,
3428 ) -> anyhow::Result<()> {
3429 self.persist_turn_item(
3430 thread_id,
3431 turn_id,
3432 &TranscriptItem::Error(ErrorRecord {
3433 message: message.clone(),
3434 }),
3435 )
3436 .await?;
3437 self.emit(RoderEvent::TurnFailed(TurnFailed {
3438 thread_id: thread_id.clone(),
3439 turn_id: turn_id.clone(),
3440 error: message,
3441 error_kind: None,
3442 usage: None,
3443 timestamp: OffsetDateTime::now_utc(),
3444 }))
3445 .await;
3446 self.complete_team_member_turn(thread_id, turn_id, TeamMemberStatus::Failed)
3447 .await?;
3448 Ok(())
3449 }
3450
3451 async fn fail_turn_due_to_deadline(
3452 &self,
3453 thread_id: &ThreadId,
3454 turn_id: &TurnId,
3455 deadline: OffsetDateTime,
3456 transcript: &[TranscriptItem],
3457 ) -> anyhow::Result<()> {
3458 let partial_result = turn_partial_result(transcript);
3459 self.emit(RoderEvent::TurnPartialResult(TurnPartialResult {
3460 thread_id: thread_id.clone(),
3461 turn_id: turn_id.clone(),
3462 summary: partial_result.clone(),
3463 timestamp: OffsetDateTime::now_utc(),
3464 }))
3465 .await;
3466 self.emit(RoderEvent::TurnDeadlineExceeded(TurnDeadlineExceeded {
3467 thread_id: thread_id.clone(),
3468 turn_id: turn_id.clone(),
3469 deadline,
3470 partial_result: partial_result.clone(),
3471 timestamp: OffsetDateTime::now_utc(),
3472 }))
3473 .await;
3474 let message = "turn deadline expired".to_string();
3475 self.persist_turn_item(
3476 thread_id,
3477 turn_id,
3478 &TranscriptItem::Error(ErrorRecord {
3479 message: format!("{message}: {partial_result}"),
3480 }),
3481 )
3482 .await?;
3483 self.emit(RoderEvent::TurnFailed(TurnFailed {
3484 thread_id: thread_id.clone(),
3485 turn_id: turn_id.clone(),
3486 error: message,
3487 error_kind: Some("deadline_timeout".to_string()),
3488 usage: None,
3489 timestamp: OffsetDateTime::now_utc(),
3490 }))
3491 .await;
3492 self.complete_team_member_turn(thread_id, turn_id, TeamMemberStatus::Failed)
3493 .await?;
3494 Ok(())
3495 }
3496
3497 async fn start_deadline_finalization(
3498 &self,
3499 thread_id: &ThreadId,
3500 turn_id: &TurnId,
3501 transcript: &mut Vec<TranscriptItem>,
3502 remaining_seconds: u64,
3503 ) -> anyhow::Result<()> {
3504 let item = TranscriptItem::UserMessage(crate::deadline_policy::finalization_message(
3505 remaining_seconds,
3506 ));
3507 self.persist_turn_item(thread_id, turn_id, &item).await?;
3508 transcript.push(item);
3509 self.emit(RoderEvent::TurnPartialResult(TurnPartialResult {
3510 thread_id: thread_id.clone(),
3511 turn_id: turn_id.clone(),
3512 summary: turn_partial_result(transcript),
3513 timestamp: OffsetDateTime::now_utc(),
3514 }))
3515 .await;
3516 Ok(())
3517 }
3518
3519 async fn fail_turn_due_to_reliability_limit(
3520 &self,
3521 thread_id: &ThreadId,
3522 turn_id: &TurnId,
3523 provider: &str,
3524 model: &str,
3525 limit: ReliabilityLimitHit,
3526 transcript: &[TranscriptItem],
3527 ) -> anyhow::Result<()> {
3528 self.emit(RoderEvent::ReliabilityLimitRecorded(
3529 ReliabilityLimitRecorded {
3530 context: ReliabilityContext {
3531 thread_id: thread_id.clone(),
3532 turn_id: turn_id.clone(),
3533 tool_id: None,
3534 tool_name: None,
3535 provider: Some(provider.to_string()),
3536 model: Some(model.to_string()),
3537 },
3538 error_class: limit.error_class,
3539 limit_kind: limit.limit_kind,
3540 decision: limit.decision,
3541 current: limit.current,
3542 limit: limit.limit,
3543 details: ReliabilityDetails::redacted(&limit.message),
3544 timestamp: OffsetDateTime::now_utc(),
3545 },
3546 ))
3547 .await;
3548 let partial_result = turn_partial_result(transcript);
3549 self.emit(RoderEvent::TurnPartialResult(TurnPartialResult {
3550 thread_id: thread_id.clone(),
3551 turn_id: turn_id.clone(),
3552 summary: partial_result.clone(),
3553 timestamp: OffsetDateTime::now_utc(),
3554 }))
3555 .await;
3556 let message = format!("reliability limit reached: {}", limit.message);
3557 self.persist_turn_item(
3558 thread_id,
3559 turn_id,
3560 &TranscriptItem::Error(ErrorRecord {
3561 message: format!("{message}: {partial_result}"),
3562 }),
3563 )
3564 .await?;
3565 self.emit(RoderEvent::TurnFailed(TurnFailed {
3566 thread_id: thread_id.clone(),
3567 turn_id: turn_id.clone(),
3568 error: message,
3569 error_kind: Some("reliability_limit".to_string()),
3570 usage: None,
3571 timestamp: OffsetDateTime::now_utc(),
3572 }))
3573 .await;
3574 self.complete_team_member_turn(thread_id, turn_id, TeamMemberStatus::Failed)
3575 .await?;
3576 Ok(())
3577 }
3578
3579 async fn append_steers(
3580 &self,
3581 req: &StartTurnRequest,
3582 turn_id: &TurnId,
3583 transcript: &mut Vec<TranscriptItem>,
3584 steers: Vec<UserMessage>,
3585 ) -> anyhow::Result<()> {
3586 for mut steer in steers {
3587 steer.text = steer.text.trim().to_string();
3588 if steer.text.is_empty() && steer.images.is_empty() {
3589 continue;
3590 }
3591 let item = TranscriptItem::UserMessage(steer);
3592 self.persist_turn_item(&req.thread_id, turn_id, &item)
3593 .await?;
3594 transcript.push(item);
3595 }
3596 Ok(())
3597 }
3598
3599 async fn persist_model_profile_segment(
3600 &self,
3601 thread_id: &ThreadId,
3602 turn_id: &TurnId,
3603 profile: Option<&ModelHarnessProfile>,
3604 provider: &str,
3605 model: &str,
3606 segment: &str,
3607 ) -> anyhow::Result<()> {
3608 let item = TranscriptItem::ProviderMetadata(model_profile_segment_metadata(
3609 profile, provider, model, segment,
3610 ));
3611 self.persist_turn_item(thread_id, turn_id, &item).await
3612 }
3613
3614 fn filtered_tool_specs(
3620 &self,
3621 cfg: &RuntimeConfig,
3622 model: &str,
3623 profile: Option<&ModelHarnessProfile>,
3624 thread_allowlist: &[String],
3625 external_tools: &[roder_api::tools::ToolSpec],
3626 ) -> Vec<roder_api::tools::ToolSpec> {
3627 let mut specs = self
3628 .tool_registry
3629 .specs_for_edit_tool_with_schema_policy(
3630 edit_tool_for_model(cfg, model),
3631 schema_policy_for_model(profile),
3632 )
3633 .into_iter()
3634 .filter(|spec| {
3635 allowlist_permits(&cfg.tool_allowlist, &spec.name)
3636 && allowlist_permits(thread_allowlist, &spec.name)
3637 && !external_tools.iter().any(|tool| tool.name == spec.name)
3638 })
3639 .collect::<Vec<_>>();
3640 specs.extend(external_tools.iter().cloned());
3641 specs
3642 }
3643
3644 fn task_ledger_tool_specs(
3645 &self,
3646 profile: Option<&ModelHarnessProfile>,
3647 ) -> Vec<roder_api::tools::ToolSpec> {
3648 self.tool_registry
3649 .get(TASK_LEDGER_TOOL_NAME)
3650 .map(|tool| {
3651 tool.spec()
3652 .normalized_for_model_profile(schema_policy_for_model(profile))
3653 })
3654 .into_iter()
3655 .collect()
3656 }
3657
3658 pub(crate) fn engine_for(&self, provider: &str) -> anyhow::Result<Arc<dyn InferenceEngine>> {
3659 self.registry
3660 .inference_engine(provider)
3661 .or_else(|| {
3662 self.registry
3663 .default_inference_engine()
3664 .filter(|engine| provider.is_empty() || engine.id() == provider)
3665 })
3666 .ok_or_else(|| anyhow::anyhow!("inference provider {provider:?} is not registered"))
3667 }
3668
3669 pub async fn emit(&self, event: RoderEvent) -> EventEnvelope {
3670 let envelope = self.bus.emit(event);
3671 if let (Some(store), Some(thread_id)) = (&self.thread_store, envelope.thread_id.as_ref())
3672 && should_persist_thread_event(thread_id)
3673 {
3674 let _ = store.append_event(thread_id, &envelope).await;
3675 }
3676 let dispatcher = self
3680 .event_sink_dispatcher
3681 .get_or_init(|| async {
3682 crate::event_sink_dispatch::EventSinkDispatcher::start(
3683 &self.registry.event_sinks,
3684 self.bus.clone(),
3685 )
3686 })
3687 .await;
3688 if !dispatcher.is_empty() {
3689 dispatcher.dispatch(&envelope, &self.bus);
3690 }
3691 envelope
3692 }
3693
3694 pub async fn record_thread_item_event_kind(
3700 &self,
3701 thread_id: &ThreadId,
3702 turn_id: &TurnId,
3703 timestamp: OffsetDateTime,
3704 kind: ThreadItemEventKind,
3705 ) -> anyhow::Result<ThreadItemEvent> {
3706 let seq = self.next_thread_item_event_seq(thread_id).await?;
3707 let item_event = ThreadItemEvent {
3708 seq,
3709 event_id: format!("{turn_id}-item-event-{seq}"),
3710 thread_id: thread_id.clone(),
3711 turn_id: turn_id.clone(),
3712 timestamp,
3713 event: kind,
3714 };
3715 if let Some(store) = &self.thread_store {
3716 store.append_item_event(thread_id, &item_event).await?;
3717 }
3718 self.remember_thread_item_event(&item_event).await?;
3719 Ok(item_event)
3720 }
3721
3722 async fn next_thread_item_event_seq(&self, thread_id: &ThreadId) -> anyhow::Result<u64> {
3723 self.ensure_thread_item_cache(thread_id).await?;
3724 Ok(self
3725 .thread_item_cache
3726 .lock()
3727 .await
3728 .next_item_event_seq(thread_id))
3729 }
3730
3731 pub async fn thread_item_exists(
3732 &self,
3733 thread_id: &ThreadId,
3734 turn_id: &TurnId,
3735 item_id: &str,
3736 ) -> anyhow::Result<bool> {
3737 self.ensure_thread_item_cache(thread_id).await?;
3738 Ok(self
3739 .thread_item_cache
3740 .lock()
3741 .await
3742 .thread_item_exists(thread_id, turn_id, item_id))
3743 }
3744
3745 pub async fn current_reasoning_item_id(
3746 &self,
3747 thread_id: &ThreadId,
3748 turn_id: &TurnId,
3749 ) -> anyhow::Result<Option<String>> {
3750 self.ensure_thread_item_cache(thread_id).await?;
3751 Ok(self
3752 .thread_item_cache
3753 .lock()
3754 .await
3755 .current_reasoning_item_id(thread_id, turn_id))
3756 }
3757
3758 async fn remember_thread_item_event(&self, item_event: &ThreadItemEvent) -> anyhow::Result<()> {
3759 self.ensure_thread_item_cache(&item_event.thread_id).await?;
3760 self.thread_item_cache
3761 .lock()
3762 .await
3763 .remember_item_event(item_event);
3764 Ok(())
3765 }
3766
3767 pub async fn latest_transcript_item_index(
3768 &self,
3769 thread_id: &ThreadId,
3770 turn_id: &TurnId,
3771 ) -> anyhow::Result<Option<usize>> {
3772 self.ensure_thread_item_cache(thread_id).await?;
3773 Ok(self
3774 .thread_item_cache
3775 .lock()
3776 .await
3777 .latest_transcript_item_index(thread_id, turn_id))
3778 }
3779
3780 async fn next_transcript_item_index(
3781 &self,
3782 thread_id: &ThreadId,
3783 turn_id: &TurnId,
3784 ) -> anyhow::Result<usize> {
3785 self.ensure_thread_item_cache(thread_id).await?;
3786 Ok(self
3787 .thread_item_cache
3788 .lock()
3789 .await
3790 .next_transcript_item_index(thread_id, turn_id))
3791 }
3792
3793 async fn remember_transcript_item_index(
3794 &self,
3795 thread_id: &ThreadId,
3796 turn_id: &TurnId,
3797 item_index: usize,
3798 ) -> anyhow::Result<()> {
3799 self.ensure_thread_item_cache(thread_id).await?;
3800 self.thread_item_cache
3801 .lock()
3802 .await
3803 .remember_transcript_item_index(thread_id, turn_id, item_index);
3804 Ok(())
3805 }
3806
3807 async fn ensure_thread_item_cache(&self, thread_id: &ThreadId) -> anyhow::Result<()> {
3808 if self
3809 .thread_item_cache
3810 .lock()
3811 .await
3812 .contains_thread(thread_id)
3813 {
3814 return Ok(());
3815 }
3816
3817 let snapshot = if let Some(store) = &self.thread_store {
3818 store.load_thread(thread_id).await?
3819 } else {
3820 None
3821 };
3822 self.thread_item_cache.lock().await.ensure_thread(
3823 thread_id,
3824 ThreadItemCacheEntry::from_snapshot(snapshot.as_ref()),
3825 );
3826 Ok(())
3827 }
3828
3829 pub(crate) async fn persist_turn_item(
3830 &self,
3831 thread_id: &ThreadId,
3832 turn_id: &TurnId,
3833 item: &TranscriptItem,
3834 ) -> anyhow::Result<()> {
3835 let item_index = self.next_transcript_item_index(thread_id, turn_id).await?;
3836 let timestamp = OffsetDateTime::now_utc();
3837 self.emit(RoderEvent::TranscriptItemAppended(TranscriptItemAppended {
3838 thread_id: thread_id.clone(),
3839 turn_id: turn_id.clone(),
3840 item_type: match item {
3841 TranscriptItem::UserMessage(_) => "user_message",
3842 TranscriptItem::AssistantMessage(_) => "assistant_message",
3843 TranscriptItem::ReasoningSummary(_) => "reasoning_summary",
3844 TranscriptItem::ToolCall(_) => "tool_call",
3845 TranscriptItem::ToolResult(_) => "tool_result",
3846 TranscriptItem::FileChange(_) => "file_change",
3847 TranscriptItem::ContextCompaction(_) => "context_compaction",
3848 TranscriptItem::Error(_) => "error",
3849 TranscriptItem::ProviderMetadata(_) => "provider_metadata",
3850 }
3851 .to_string(),
3852 item_index: Some(item_index),
3853 item: Some(item.clone()),
3854 timestamp,
3855 }))
3856 .await;
3857 self.remember_transcript_item_index(thread_id, turn_id, item_index)
3858 .await?;
3859 Ok(())
3860 }
3861}
3862
3863fn transcript_has_images(transcript: &[TranscriptItem]) -> bool {
3864 transcript.iter().any(|item| {
3865 matches!(
3866 item,
3867 TranscriptItem::UserMessage(message) if !message.images.is_empty()
3868 )
3869 })
3870}
3871
3872fn transcript_has_task_ledger(transcript: &[TranscriptItem]) -> bool {
3873 transcript.iter().any(|item| {
3874 matches!(
3875 item,
3876 TranscriptItem::ToolResult(result)
3877 if result.name.as_deref() == Some(TASK_LEDGER_TOOL_NAME) && !result.is_error
3878 )
3879 })
3880}
3881
3882fn task_ledger_completion_prompt(transcript: &[TranscriptItem]) -> Option<String> {
3883 let latest = transcript.iter().rev().find_map(|item| match item {
3884 TranscriptItem::ToolResult(result)
3885 if result.name.as_deref() == Some(TASK_LEDGER_TOOL_NAME) && !result.is_error =>
3886 {
3887 Some(result.result.as_str())
3888 }
3889 _ => None,
3890 })?;
3891 if !task_ledger_has_open_items(latest) {
3892 return None;
3893 }
3894
3895 let mut ledger = latest.chars().take(1500).collect::<String>();
3896 if latest.chars().nth(1500).is_some() {
3897 ledger.push_str("...");
3898 }
3899 Some(format!(
3900 "Task Ledger Completion Required: the latest task ledger still has pending or in-progress items. Do not provide a final answer yet. Use tools to complete the remaining scoreable work, create or update any required output files, then call `{TASK_LEDGER_TOOL_NAME}` with every task completed and evidence before finalizing.\n\nLatest ledger:\n{ledger}"
3901 ))
3902}
3903
3904fn task_ledger_deadline_completion_prompt(
3905 remaining_seconds: u64,
3906 reserve_seconds: u64,
3907 completion_prompt: &str,
3908) -> String {
3909 format!(
3910 "Eval deadline scoreable completion: {remaining_seconds} seconds remain in the {reserve_seconds}-second finalization reserve. Do not browse, search, or start slow work. Use the available tools now to create or update the required scoreable output files, run only a quick local check if needed, then update the task ledger to completed before finalizing.\n\n{completion_prompt}"
3911 )
3912}
3913
3914fn task_ledger_scoreable_checkpoint_prompt(
3915 remaining_seconds: u64,
3916 completion_prompt: &str,
3917) -> String {
3918 format!(
3919 "Scoreable Output Checkpoint: {remaining_seconds} seconds remain before the eval deadline. Before any further research, browsing, or long commands, use tools now to ensure the required output file(s) exist with the best evidence-backed answer, even if provisional. If a scoreable file already exists, read it and preserve that candidate unless you have stronger task-specific evidence for a replacement. Do not overwrite a plausible dated, historical, or local-evidence candidate with a current live-page, partial-coverage, or weaker guess merely to refresh the checkpoint. You may continue refining afterward, but do not apologize or finalize until the scoreable file exists and the task ledger is updated.\n\n{completion_prompt}"
3920 )
3921}
3922
3923fn task_ledger_has_open_items(ledger: &str) -> bool {
3924 ledger.lines().any(|line| {
3925 let line = line.trim_start();
3926 line.starts_with("- pending:") || line.starts_with("- in_progress:")
3927 })
3928}
3929
3930fn turn_deadline_for_config(cfg: &RuntimeConfig) -> Option<OffsetDateTime> {
3931 if !cfg.runtime_profile.is_non_interactive() {
3932 return None;
3933 }
3934 cfg.turn_deadline_seconds
3935 .filter(|seconds| *seconds > 0)
3936 .map(|seconds| OffsetDateTime::now_utc() + Duration::seconds(seconds as i64))
3937}
3938
3939fn deadline_expired(deadline: OffsetDateTime) -> bool {
3940 OffsetDateTime::now_utc() >= deadline
3941}
3942
3943pub(crate) fn deadline_remaining_seconds(deadline: Option<OffsetDateTime>) -> Option<u64> {
3944 let deadline = deadline?;
3945 if deadline <= OffsetDateTime::now_utc() {
3946 return Some(0);
3947 }
3948 Some(
3949 (deadline - OffsetDateTime::now_utc())
3950 .unsigned_abs()
3951 .as_secs()
3952 .max(1),
3953 )
3954}
3955
3956fn deadline_instant(deadline: OffsetDateTime) -> tokio::time::Instant {
3957 let now = OffsetDateTime::now_utc();
3958 if deadline <= now {
3959 return tokio::time::Instant::now();
3960 }
3961 tokio::time::Instant::now() + (deadline - now).unsigned_abs()
3962}
3963
3964fn inference_timeout_deadline(
3965 deadline: Option<OffsetDateTime>,
3966 runtime_profile: RuntimeProfile,
3967 task_ledger_required: bool,
3968 reserve_seconds: u64,
3969 finalization_requested: bool,
3970 task_ledger_scoreable_checkpoints: u8,
3971 transcript: &[TranscriptItem],
3972) -> Option<(OffsetDateTime, InferenceTimeoutAction)> {
3973 let deadline = deadline?;
3974 if runtime_profile == RuntimeProfile::Eval
3975 && task_ledger_required
3976 && !finalization_requested
3977 && task_ledger_scoreable_checkpoints < TASK_LEDGER_SCOREABLE_CHECKPOINT_LIMIT
3978 && TASK_LEDGER_SCOREABLE_CHECKPOINT_SECONDS > reserve_seconds
3979 && task_ledger_completion_prompt(transcript).is_some()
3980 {
3981 let checkpoint_deadline =
3982 deadline - Duration::seconds(TASK_LEDGER_SCOREABLE_CHECKPOINT_SECONDS as i64);
3983 if checkpoint_deadline > OffsetDateTime::now_utc() {
3984 return Some((
3985 checkpoint_deadline,
3986 InferenceTimeoutAction::ScoreableCheckpoint,
3987 ));
3988 }
3989 }
3990 if runtime_profile == RuntimeProfile::Eval && !finalization_requested {
3991 return Some((
3992 deadline - Duration::seconds(reserve_seconds as i64),
3993 InferenceTimeoutAction::Finalization,
3994 ));
3995 }
3996 Some((deadline, InferenceTimeoutAction::Finalization))
3997}
3998
3999fn turn_partial_result(transcript: &[TranscriptItem]) -> String {
4000 let tool_results = transcript
4001 .iter()
4002 .filter(|item| matches!(item, TranscriptItem::ToolResult(_)))
4003 .count();
4004 let assistant_messages = transcript
4005 .iter()
4006 .filter(|item| matches!(item, TranscriptItem::AssistantMessage(_)))
4007 .count();
4008 format!(
4009 "partial turn state: {} transcript items, {assistant_messages} assistant messages, {tool_results} tool results",
4010 transcript.len()
4011 )
4012}
4013
4014fn reasoning_for_model(cfg: &RuntimeConfig, model: &str) -> ReasoningConfig {
4015 let level = effective_reasoning_for_model(cfg, model);
4016 match level.as_str() {
4017 "" | REASONING_NONE => ReasoningConfig::default(),
4018 level => ReasoningConfig {
4019 enabled: true,
4020 level: Some(level.to_string()),
4021 },
4022 }
4023}
4024
4025fn server_side_compaction_threshold(cfg: &RuntimeConfig, model: &str) -> Option<u32> {
4026 let entry = lookup_model(model)?;
4027 if !entry.supports_compaction {
4028 return None;
4029 }
4030 cfg.auto_compact_token_limit
4031 .or_else(|| {
4032 model_profile_for_model(cfg, model).and_then(|profile| profile.auto_compact_token_limit)
4033 })
4034 .or(Some(entry.auto_compact_token_limit))
4035 .filter(|threshold| *threshold > 0)
4036}
4037
4038pub(crate) fn tool_search_for_provider_model(
4039 cfg: &RuntimeConfig,
4040 provider: &str,
4041 model: &str,
4042) -> ToolSearchConfig {
4043 let mut resolved = cfg.tool_search.clone();
4044 if let Some(provider_config) = cfg.provider_tool_search.get(provider) {
4045 provider_config.apply_to(&mut resolved);
4046 }
4047 if let Some(model_config) = cfg.model_tool_search.get(model) {
4048 model_config.apply_to(&mut resolved);
4049 }
4050 resolved
4051}
4052
4053fn parallel_tool_calls_for_model(cfg: &RuntimeConfig, model: &str) -> bool {
4054 cfg.model_parallel_tool_calls
4055 .get(model)
4056 .copied()
4057 .or_else(|| {
4058 model_profile_for_model(cfg, model).and_then(|profile| profile.parallel_tool_calls)
4059 })
4060 .unwrap_or(true)
4061}
4062
4063fn agent_swarm_child_count(arguments: &str) -> usize {
4066 serde_json::from_str::<roder_api::subagents::AgentSwarmRequest>(arguments)
4067 .map(|request| request.items.len() + request.resume_agent_ids.len())
4068 .unwrap_or(0)
4069}
4070
4071fn parse_swarm_counts(text: &str) -> Option<(usize, usize, usize)> {
4075 if !text.contains("<agent_swarm_result>") {
4076 return None;
4077 }
4078 let bucket = |label: &str| -> usize {
4079 let needle = format!("{label}: ");
4080 text.find(&needle)
4081 .map(|start| start + needle.len())
4082 .map(|start| {
4083 text[start..]
4084 .chars()
4085 .take_while(|c| c.is_ascii_digit())
4086 .collect::<String>()
4087 })
4088 .and_then(|digits| digits.parse().ok())
4089 .unwrap_or(0)
4090 };
4091 Some((bucket("completed"), bucket("failed"), bucket("aborted")))
4092}
4093
4094fn effective_reasoning_for_model(cfg: &RuntimeConfig, model: &str) -> String {
4095 let base_reasoning = default_effective_reasoning_for_model(cfg, model);
4096 if cfg.dynamic_workflows.effort_profile == DynamicWorkflowEffortProfile::Ultracode {
4097 return ultracode_reasoning_level_for_model(
4098 model,
4099 &cfg.speed_policy.ultracode_reasoning,
4100 &base_reasoning,
4101 );
4102 }
4103 base_reasoning
4104}
4105
4106fn default_effective_reasoning_for_model(cfg: &RuntimeConfig, model: &str) -> String {
4107 let Some(entry) = lookup_model(model) else {
4108 return cfg
4109 .reasoning
4110 .clone()
4111 .unwrap_or_else(|| REASONING_NONE.to_string());
4112 };
4113 if entry.supported_reasoning.is_empty() {
4114 return REASONING_NONE.to_string();
4115 }
4116 cfg.reasoning
4117 .as_deref()
4118 .filter(|reasoning| {
4119 entry
4120 .supported_reasoning
4121 .iter()
4122 .any(|option| option.effort == *reasoning)
4123 })
4124 .map(str::to_string)
4125 .or_else(|| {
4126 model_profile_for_model(cfg, model)
4127 .and_then(|profile| profile.reasoning.orientation)
4128 .filter(|reasoning| {
4129 entry
4130 .supported_reasoning
4131 .iter()
4132 .any(|option| option.effort == reasoning)
4133 })
4134 })
4135 .unwrap_or_else(|| entry.default_reasoning.to_string())
4136}
4137
4138fn validate_reasoning_effort(model: &str, effort: &str) -> anyhow::Result<()> {
4139 if effort == REASONING_NONE && !model_supports_reasoning(model, effort) {
4140 return Ok(());
4141 }
4142 let Some(entry) = lookup_model(model) else {
4143 return Ok(());
4144 };
4145 if entry
4146 .supported_reasoning
4147 .iter()
4148 .any(|option| option.effort == effort)
4149 {
4150 Ok(())
4151 } else {
4152 anyhow::bail!("model {model} does not support reasoning effort {effort}")
4153 }
4154}
4155
4156fn validate_runtime_config_reasoning(cfg: &RuntimeConfig) -> anyhow::Result<()> {
4157 let Some(reasoning) = cfg.reasoning.as_deref() else {
4158 return Ok(());
4159 };
4160 let Some(entry) = lookup_model(&cfg.default_model) else {
4161 return Ok(());
4162 };
4163 if entry.provider != PROVIDER_GEMINI {
4164 return Ok(());
4165 }
4166 validate_reasoning_effort(&cfg.default_model, reasoning)
4167}
4168
4169fn validate_runtime_inference_router_config(
4170 registry: &ExtensionRegistry,
4171 cfg: &RuntimeConfig,
4172) -> anyhow::Result<()> {
4173 if !cfg.inference_router.enabled {
4174 return Ok(());
4175 }
4176 let Some(router_id) = cfg.inference_router.router_id.as_deref() else {
4177 anyhow::bail!("inference_router.enabled requires inference_router.router");
4178 };
4179 if registry.inference_router(router_id).is_some() {
4180 return Ok(());
4181 }
4182 let available = registry
4183 .inference_routers
4184 .iter()
4185 .map(|router| router.id())
4186 .collect::<Vec<_>>()
4187 .join(", ");
4188 if available.is_empty() {
4189 anyhow::bail!("inference router {router_id:?} is not registered");
4190 }
4191 anyhow::bail!(
4192 "inference router {router_id:?} is not registered; available routers: {available}"
4193 );
4194}
4195
4196fn model_supports_reasoning(model: &str, effort: &str) -> bool {
4197 lookup_model(model)
4198 .map(|entry| {
4199 entry
4200 .supported_reasoning
4201 .iter()
4202 .any(|option| option.effort == effort)
4203 })
4204 .unwrap_or(false)
4205}
4206
4207fn is_final_answer_phase(phase: Option<&str>) -> bool {
4208 phase.is_none_or(|phase| phase.is_empty() || phase == FINAL_ANSWER_PHASE)
4209}
4210
4211fn edit_tool_for_model<'a>(cfg: &'a RuntimeConfig, model: &'a str) -> Option<&'a str> {
4212 cfg.model_edit_tools
4213 .get(model)
4214 .map(String::as_str)
4215 .or_else(|| {
4216 cfg.model_profiles
4217 .get(model)
4218 .and_then(|profile| profile.edit_tool.as_deref())
4219 })
4220 .or_else(|| lookup_model(model).and_then(|entry| entry.edit_tool))
4221 .or(Some(EDIT_TOOL_EDIT))
4222}
4223
4224fn model_profile_for_model(cfg: &RuntimeConfig, model: &str) -> Option<ModelHarnessProfile> {
4225 cfg.model_profiles
4226 .get(model)
4227 .cloned()
4228 .or_else(|| built_in_model_profile(model))
4229}
4230
4231pub(crate) fn allowlist_permits(allowlist: &[String], tool_name: &str) -> bool {
4232 allowlist.is_empty() || allowlist.iter().any(|allowed| allowed == tool_name)
4233}
4234
4235fn model_profile_for_provider_model(
4245 cfg: &RuntimeConfig,
4246 provider: &str,
4247 model: &str,
4248) -> Option<ModelHarnessProfile> {
4249 cfg.model_profiles
4250 .get(model)
4251 .cloned()
4252 .or_else(|| built_in_model_profile_for_provider(provider, model))
4253}
4254
4255fn schema_policy_for_model(profile: Option<&ModelHarnessProfile>) -> ModelSchemaPolicy {
4256 profile
4257 .map(|profile| profile.schema_policy)
4258 .unwrap_or_default()
4259}
4260
4261fn model_profile_segment_metadata(
4262 profile: Option<&ModelHarnessProfile>,
4263 provider: &str,
4264 model: &str,
4265 segment: &str,
4266) -> serde_json::Value {
4267 serde_json::json!({
4268 "kind": MODEL_PROFILE_TRACE_KIND,
4269 "segment": segment,
4270 "provider": provider,
4271 "model": model,
4272 "profileModel": profile.map(|profile| profile.model.as_str()).unwrap_or(model),
4273 "providerFamily": profile.map(|profile| profile.provider_family),
4274 "editTool": profile.and_then(|profile| profile.edit_tool.as_deref()),
4275 "schemaPolicy": profile.map(|profile| profile.schema_policy),
4276 "instructionOverlay": profile.map(|profile| profile.instruction_overlay),
4277 "parallelToolCalls": profile.and_then(|profile| profile.parallel_tool_calls),
4278 "autoCompactTokenLimit": profile.and_then(|profile| profile.auto_compact_token_limit),
4279 })
4280}
4281
4282fn model_switch_summary(
4283 transcript: &[TranscriptItem],
4284 profile: Option<&ModelHarnessProfile>,
4285 provider: &str,
4286 model: &str,
4287 tools: &[roder_api::tools::ToolSpec],
4288) -> Option<String> {
4289 let previous = latest_model_profile_segment(transcript)?;
4290 let previous_model = previous
4291 .get("model")
4292 .and_then(serde_json::Value::as_str)
4293 .unwrap_or_default();
4294 let previous_provider = previous
4295 .get("provider")
4296 .and_then(serde_json::Value::as_str)
4297 .unwrap_or_default();
4298 if previous_model == model && previous_provider == provider {
4299 return None;
4300 }
4301
4302 let previous_profile = previous
4303 .get("profileModel")
4304 .and_then(serde_json::Value::as_str)
4305 .unwrap_or(previous_model);
4306 let current_profile = profile
4307 .map(|profile| profile.model.as_str())
4308 .unwrap_or(model);
4309 let previous_edit_tool = previous
4310 .get("editTool")
4311 .and_then(serde_json::Value::as_str)
4312 .unwrap_or("none");
4313 let current_edit_tool = profile
4314 .and_then(|profile| profile.edit_tool.as_deref())
4315 .unwrap_or("none");
4316 let tool_names = tools
4317 .iter()
4318 .map(|tool| tool.name.as_str())
4319 .take(12)
4320 .collect::<Vec<_>>()
4321 .join(", ");
4322 Some(format!(
4323 "{MODEL_SWITCH_SUMMARY_PREFIX} previous profile {previous_provider}/{previous_profile} used edit tool {previous_edit_tool}. Current profile {provider}/{current_profile} uses edit tool {current_edit_tool}. Available tools now: {}.",
4324 if tool_names.is_empty() {
4325 "none"
4326 } else {
4327 &tool_names
4328 }
4329 ))
4330}
4331
4332fn latest_model_profile_segment(transcript: &[TranscriptItem]) -> Option<&serde_json::Value> {
4333 transcript.iter().rev().find_map(|item| {
4334 let TranscriptItem::ProviderMetadata(value) = item else {
4335 return None;
4336 };
4337 (value.get("kind").and_then(serde_json::Value::as_str) == Some(MODEL_PROFILE_TRACE_KIND))
4338 .then_some(value)
4339 })
4340}
4341
4342pub fn validate_edit_tool(value: &str) -> anyhow::Result<()> {
4343 match value.trim() {
4344 EDIT_TOOL_PATCH | EDIT_TOOL_EDIT => Ok(()),
4345 _ => anyhow::bail!(
4346 "unsupported edit_tool {value:?}; allowed values: {EDIT_TOOL_PATCH}, {EDIT_TOOL_EDIT}"
4347 ),
4348 }
4349}
4350
4351fn should_persist_thread_event(thread_id: &str) -> bool {
4352 !is_synthetic_event_thread_id(thread_id)
4353}
4354
4355#[cfg(test)]
4356mod tests {
4357 use super::*;
4358 use futures::stream;
4359 use roder_api::catalog::{
4360 PROVIDER_MOCK, REASONING_HIGH, REASONING_LOW, REASONING_MEDIUM, REASONING_MINIMAL,
4361 REASONING_NONE, REASONING_XHIGH,
4362 };
4363 use roder_api::extension::ExtensionRegistryBuilder;
4364 use roder_api::inference::{
4365 CompletionMetadata, InferenceCapabilities, InferenceEngine, InferenceEventStream,
4366 InferenceProviderContext, InferenceTurnContext, MessageDelta, ModelDescriptor,
4367 ModelInstructionOverlay, ModelProfileReasoning, ModelSchemaPolicy, ProviderFamily,
4368 ReasoningEffortDescriptor,
4369 };
4370 use roder_api::inference_routing::{
4371 InferenceRouter, InferenceRoutingContext, InferenceRoutingDecision, InferenceRoutingOutcome,
4372 };
4373 use roder_api::thread::ThreadStoreFactory;
4374 use roder_api::tools::{ToolContributor, ToolExecutor, ToolSpec};
4375 use roder_ext_jsonl_thread_store::store::JsonlThreadStoreFactory;
4376 use std::sync::Mutex as StdMutex;
4377
4378 fn test_workspace() -> String {
4379 std::env::current_dir().unwrap().display().to_string()
4380 }
4381
4382 struct MetadataMissingStore;
4383
4384 #[async_trait::async_trait]
4385 impl ThreadStore for MetadataMissingStore {
4386 fn id(&self) -> roder_api::thread::ThreadStoreId {
4387 "metadata-missing-store".to_string()
4388 }
4389
4390 async fn create_thread(&self, metadata: ThreadMetadata) -> anyhow::Result<ThreadMetadata> {
4391 Ok(metadata)
4392 }
4393
4394 async fn list_threads(&self) -> anyhow::Result<Vec<ThreadMetadata>> {
4395 Ok(Vec::new())
4396 }
4397
4398 async fn load_thread(
4399 &self,
4400 _thread_id: &ThreadId,
4401 ) -> anyhow::Result<Option<ThreadSnapshot>> {
4402 Ok(Some(ThreadSnapshot {
4403 metadata: None,
4404 ..ThreadSnapshot::default()
4405 }))
4406 }
4407
4408 async fn append_event(
4409 &self,
4410 _thread_id: &ThreadId,
4411 _envelope: &EventEnvelope,
4412 ) -> anyhow::Result<()> {
4413 Ok(())
4414 }
4415 }
4416
4417 struct MetadataMissingStoreFactory;
4418
4419 impl ThreadStoreFactory for MetadataMissingStoreFactory {
4420 fn id(&self) -> roder_api::thread::ThreadStoreId {
4421 "metadata-missing-store".to_string()
4422 }
4423
4424 fn create(&self) -> Arc<dyn ThreadStore> {
4425 Arc::new(MetadataMissingStore)
4426 }
4427 }
4428
4429 #[test]
4430 fn synthetic_app_server_events_are_not_thread_events() {
4431 for thread_id in ["app-server", "runtime", "thread-workflow"] {
4432 assert!(!should_persist_thread_event(thread_id));
4433 }
4434 assert!(should_persist_thread_event("thread-discovery"));
4435 assert!(should_persist_thread_event("thread-plan"));
4436 assert!(should_persist_thread_event("thread-process"));
4437 assert!(should_persist_thread_event("thread-1"));
4438 }
4439
4440 #[test]
4441 fn server_side_compaction_uses_catalog_ninety_percent_default() {
4442 assert_eq!(
4443 server_side_compaction_threshold(&RuntimeConfig::default(), "gpt-5.5"),
4444 Some(945_000)
4445 );
4446 assert_eq!(
4447 server_side_compaction_threshold(&RuntimeConfig::default(), "gpt-5.3-codex-spark"),
4448 Some(115_200)
4449 );
4450 }
4451
4452 #[test]
4453 fn server_side_compaction_respects_explicit_config_override() {
4454 let cfg = RuntimeConfig {
4455 auto_compact_token_limit: Some(123_456),
4456 ..RuntimeConfig::default()
4457 };
4458
4459 assert_eq!(
4460 server_side_compaction_threshold(&cfg, "gpt-5.5"),
4461 Some(123_456)
4462 );
4463 }
4464
4465 #[tokio::test]
4466 async fn pre_request_compaction_runs_when_server_side_model_is_at_context_window() {
4467 let captured = Arc::new(StdMutex::new(None));
4468 let mut builder = ExtensionRegistryBuilder::new();
4469 builder.inference_engine(Arc::new(CapturingEngine {
4470 request: captured.clone(),
4471 }));
4472 let thread_root = std::env::temp_dir().join(format!(
4473 "roder-pre-request-compaction-{}",
4474 uuid::Uuid::new_v4()
4475 ));
4476 builder.thread_store_factory(Arc::new(JsonlThreadStoreFactory {
4477 base_path: thread_root.clone(),
4478 }));
4479 let runtime = Arc::new(
4480 Runtime::new(
4481 builder.build().unwrap(),
4482 RuntimeConfig {
4483 default_provider: PROVIDER_MOCK.to_string(),
4484 default_model: "gpt-5.5".to_string(),
4485 file_backed_dynamic_context: true,
4486 ..RuntimeConfig::default()
4487 },
4488 )
4489 .unwrap(),
4490 );
4491 let thread_id = runtime
4492 .create_thread(Some("Pre-request compaction".to_string()))
4493 .await
4494 .unwrap()
4495 .thread_id;
4496 let old_turn = "old-turn".to_string();
4497 runtime
4498 .persist_turn_item(
4499 &thread_id,
4500 &old_turn,
4501 &TranscriptItem::UserMessage(UserMessage::text("old context ".repeat(4_300_000))),
4502 )
4503 .await
4504 .unwrap();
4505
4506 let mut events = runtime.subscribe_events();
4507 runtime
4508 .start_turn(StartTurnRequest {
4509 thread_id: thread_id.clone(),
4510 message: "continue".to_string(),
4511 images: Vec::new(),
4512 provider_override: None,
4513 model_override: None,
4514 reasoning_override: None,
4515 workspace: test_workspace(),
4516 instructions: InstructionBundle::default(),
4517 developer_context: None,
4518 task_ledger_required: false,
4519 })
4520 .await
4521 .unwrap();
4522 loop {
4523 let envelope = tokio::time::timeout(std::time::Duration::from_secs(5), events.recv())
4524 .await
4525 .unwrap()
4526 .unwrap();
4527 if envelope.thread_id.as_deref() == Some(&thread_id)
4528 && matches!(envelope.event, RoderEvent::TurnCompleted(_))
4529 {
4530 break;
4531 }
4532 }
4533
4534 let request = captured.lock().unwrap().clone().unwrap();
4535 assert!(
4536 matches!(
4537 request.transcript.first(),
4538 Some(TranscriptItem::ContextCompaction(_))
4539 ),
4540 "provider request should start with a local emergency compaction item"
4541 );
4542 assert!(
4543 request.transcript.len() < 4,
4544 "provider request should not replay the full oversized prior transcript: {:?}",
4545 request.transcript
4546 );
4547
4548 let _ = std::fs::remove_dir_all(thread_root);
4549 }
4550
4551 #[tokio::test]
4552 async fn continue_after_context_window_failure_compacts_before_provider_request() {
4553 let captured = Arc::new(StdMutex::new(None));
4554 let mut builder = ExtensionRegistryBuilder::new();
4555 builder.inference_engine(Arc::new(CapturingEngine {
4556 request: captured.clone(),
4557 }));
4558 let thread_root = std::env::temp_dir().join(format!(
4559 "roder-context-failure-continue-{}",
4560 uuid::Uuid::new_v4()
4561 ));
4562 builder.thread_store_factory(Arc::new(JsonlThreadStoreFactory {
4563 base_path: thread_root.clone(),
4564 }));
4565 let runtime = Arc::new(
4566 Runtime::new(
4567 builder.build().unwrap(),
4568 RuntimeConfig {
4569 default_provider: PROVIDER_MOCK.to_string(),
4570 default_model: "gpt-5.5".to_string(),
4571 file_backed_dynamic_context: true,
4572 ..RuntimeConfig::default()
4573 },
4574 )
4575 .unwrap(),
4576 );
4577 let thread_id = runtime
4578 .create_thread(Some("Context failure continue".to_string()))
4579 .await
4580 .unwrap()
4581 .thread_id;
4582 let failed_turn = "failed-turn".to_string();
4583 runtime
4584 .persist_turn_item(
4585 &thread_id,
4586 &failed_turn,
4587 &TranscriptItem::UserMessage(UserMessage::text("old work ".repeat(10_000))),
4588 )
4589 .await
4590 .unwrap();
4591 runtime
4592 .persist_turn_item(
4593 &thread_id,
4594 &failed_turn,
4595 &TranscriptItem::Error(ErrorRecord {
4596 message: "Your input exceeds the context window of this model. Please adjust your input and try again."
4597 .to_string(),
4598 }),
4599 )
4600 .await
4601 .unwrap();
4602
4603 let mut events = runtime.subscribe_events();
4604 runtime
4605 .start_turn(StartTurnRequest {
4606 thread_id: thread_id.clone(),
4607 message: "continue".to_string(),
4608 images: Vec::new(),
4609 provider_override: None,
4610 model_override: None,
4611 reasoning_override: None,
4612 workspace: test_workspace(),
4613 instructions: InstructionBundle::default(),
4614 developer_context: None,
4615 task_ledger_required: false,
4616 })
4617 .await
4618 .unwrap();
4619 loop {
4620 let envelope = tokio::time::timeout(std::time::Duration::from_secs(5), events.recv())
4621 .await
4622 .unwrap()
4623 .unwrap();
4624 if envelope.thread_id.as_deref() == Some(&thread_id)
4625 && matches!(envelope.event, RoderEvent::TurnCompleted(_))
4626 {
4627 break;
4628 }
4629 }
4630
4631 let request = captured.lock().unwrap().clone().unwrap();
4632 assert!(
4633 matches!(
4634 request.transcript.first(),
4635 Some(TranscriptItem::ContextCompaction(_))
4636 ),
4637 "provider request after context-window failure should start with local compaction"
4638 );
4639 assert!(
4640 request
4641 .transcript
4642 .iter()
4643 .any(|item| matches!(item, TranscriptItem::UserMessage(message) if message.text == "continue")),
4644 "current continue prompt must be preserved: {:?}",
4645 request.transcript
4646 );
4647 assert!(
4648 !request.transcript.iter().any(
4649 |item| matches!(item, TranscriptItem::Error(error) if error.message.contains("context window"))
4650 ),
4651 "raw prior context-window error should be summarized, not replayed: {:?}",
4652 request.transcript
4653 );
4654
4655 let _ = std::fs::remove_dir_all(thread_root);
4656 }
4657
4658 #[tokio::test]
4659 async fn workspace_for_thread_falls_back_when_metadata_is_missing() {
4660 let workspace = test_workspace();
4661 let mut builder = ExtensionRegistryBuilder::new();
4662 builder.inference_engine(Arc::new(FakeInferenceEngine));
4663 builder.thread_store_factory(Arc::new(MetadataMissingStoreFactory));
4664 let runtime = Runtime::new(
4665 builder.build().unwrap(),
4666 RuntimeConfig {
4667 workspace: Some(workspace.clone()),
4668 ..RuntimeConfig::default()
4669 },
4670 )
4671 .unwrap();
4672
4673 let resolved = runtime
4674 .workspace_for_thread(&ThreadId::from("thread-workflow"))
4675 .await
4676 .unwrap();
4677
4678 assert_eq!(resolved, workspace);
4679 }
4680
4681 #[tokio::test]
4682 async fn automations_can_create_project_thread_with_model_overrides() {
4683 let runtime = Runtime::fake().unwrap();
4684 let workspace = std::env::temp_dir().join("project");
4685 let metadata = runtime
4686 .create_thread_with(CreateThreadRequest {
4687 title: Some("Automation: nightly status".to_string()),
4688 workspace: workspace.display().to_string(),
4689 workspace_id: None,
4690 root_id: None,
4691 provider: Some("mock".to_string()),
4692 model: Some("mock".to_string()),
4693 selection_mode: None,
4694 tool_allowlist: Vec::new(),
4695 developer_instructions: None,
4696 external_tools: Vec::new(),
4697 runner: None,
4698 })
4699 .await
4700 .unwrap();
4701
4702 assert_eq!(
4703 metadata.title.as_deref(),
4704 Some("Automation: nightly status")
4705 );
4706 assert_eq!(metadata.workspace, workspace.display().to_string());
4707 assert_eq!(metadata.provider.as_deref(), Some("mock"));
4708 assert_eq!(metadata.model.as_deref(), Some("mock"));
4709 }
4710
4711 #[test]
4712 fn server_side_compaction_is_only_enabled_for_supported_models() {
4713 let cfg = RuntimeConfig {
4714 auto_compact_token_limit: Some(123_456),
4715 ..RuntimeConfig::default()
4716 };
4717
4718 assert_eq!(server_side_compaction_threshold(&cfg, "mock"), None);
4719 assert_eq!(
4720 server_side_compaction_threshold(&cfg, "codex-auto-review"),
4721 None
4722 );
4723 }
4724
4725 #[test]
4726 fn reasoning_is_disabled_for_models_without_reasoning_support() {
4727 let cfg = RuntimeConfig {
4728 reasoning: Some(REASONING_HIGH.to_string()),
4729 ..RuntimeConfig::default()
4730 };
4731
4732 assert_eq!(
4733 effective_reasoning_for_model(&cfg, "claude-haiku-4-5-20251001"),
4734 REASONING_NONE
4735 );
4736 assert_eq!(
4737 reasoning_for_model(&cfg, "claude-haiku-4-5-20251001"),
4738 ReasoningConfig::default()
4739 );
4740 }
4741
4742 #[test]
4743 fn unsupported_configured_reasoning_falls_back_to_model_default() {
4744 let cfg = RuntimeConfig {
4745 reasoning: Some(REASONING_MINIMAL.to_string()),
4746 ..RuntimeConfig::default()
4747 };
4748
4749 assert_eq!(
4750 effective_reasoning_for_model(&cfg, "gpt-5.5"),
4751 REASONING_MEDIUM
4752 );
4753 }
4754
4755 #[test]
4756 fn unsupported_configured_gemini_reasoning_is_rejected() {
4757 let mut builder = ExtensionRegistryBuilder::new();
4758 builder.inference_engine(std::sync::Arc::new(FakeInferenceEngine));
4759
4760 let err = match Runtime::new(
4761 builder.build().unwrap(),
4762 RuntimeConfig {
4763 default_model: "gemini-3.5-flash".to_string(),
4764 reasoning: Some(REASONING_XHIGH.to_string()),
4765 ..RuntimeConfig::default()
4766 },
4767 ) {
4768 Ok(_) => panic!("expected unsupported Gemini reasoning to be rejected"),
4769 Err(err) => err,
4770 };
4771
4772 assert!(
4773 err.to_string()
4774 .contains("model gemini-3.5-flash does not support reasoning effort xhigh")
4775 );
4776 }
4777
4778 #[tokio::test]
4779 async fn selecting_none_for_non_reasoning_model_preserves_stored_preference() {
4780 let runtime = Runtime::new(
4781 Runtime::fake().unwrap().registry,
4782 RuntimeConfig {
4783 reasoning: Some(REASONING_HIGH.to_string()),
4784 ..RuntimeConfig::default()
4785 },
4786 )
4787 .unwrap();
4788
4789 let cfg = runtime
4790 .select_provider(
4791 roder_api::catalog::PROVIDER_MOCK.to_string(),
4792 Some("claude-haiku-4-5-20251001".to_string()),
4793 Some(REASONING_NONE.to_string()),
4794 )
4795 .await
4796 .unwrap();
4797
4798 assert_eq!(cfg.reasoning.as_deref(), Some(REASONING_HIGH));
4799 assert_eq!(runtime.effective_reasoning().await, REASONING_NONE);
4800 }
4801
4802 #[tokio::test]
4803 async fn selecting_none_for_model_that_supports_none_updates_preference() {
4804 let runtime = Runtime::new(
4805 Runtime::fake().unwrap().registry,
4806 RuntimeConfig {
4807 reasoning: Some(REASONING_HIGH.to_string()),
4808 ..RuntimeConfig::default()
4809 },
4810 )
4811 .unwrap();
4812
4813 let cfg = runtime
4814 .select_provider(
4815 roder_api::catalog::PROVIDER_MOCK.to_string(),
4816 Some("mock".to_string()),
4817 Some(REASONING_NONE.to_string()),
4818 )
4819 .await
4820 .unwrap();
4821
4822 assert_eq!(cfg.reasoning.as_deref(), Some(REASONING_NONE));
4823 }
4824
4825 #[test]
4826 fn parallel_tool_calls_default_on_with_model_override() {
4827 assert!(parallel_tool_calls_for_model(
4828 &RuntimeConfig::default(),
4829 "custom-model"
4830 ));
4831
4832 let cfg = RuntimeConfig {
4833 model_parallel_tool_calls: std::collections::HashMap::from([(
4834 "custom-model".to_string(),
4835 false,
4836 )]),
4837 ..RuntimeConfig::default()
4838 };
4839
4840 assert!(!parallel_tool_calls_for_model(&cfg, "custom-model"));
4841 assert!(parallel_tool_calls_for_model(&cfg, "other-model"));
4842 }
4843
4844 #[test]
4845 fn profile_parallel_tool_calls_applies_between_config_and_default() {
4846 let cfg = RuntimeConfig {
4847 model_profiles: std::collections::HashMap::from([(
4848 "gpt-5.5".to_string(),
4849 test_model_profile("gpt-5.5"),
4850 )]),
4851 ..RuntimeConfig::default()
4852 };
4853
4854 assert!(!parallel_tool_calls_for_model(&cfg, "gpt-5.5"));
4855
4856 let cfg = RuntimeConfig {
4857 model_parallel_tool_calls: std::collections::HashMap::from([(
4858 "gpt-5.5".to_string(),
4859 true,
4860 )]),
4861 ..cfg
4862 };
4863
4864 assert!(parallel_tool_calls_for_model(&cfg, "gpt-5.5"));
4865 }
4866
4867 struct CapturingEngine {
4868 request: Arc<StdMutex<Option<AgentInferenceRequest>>>,
4869 }
4870
4871 #[async_trait::async_trait]
4872 impl InferenceEngine for CapturingEngine {
4873 fn id(&self) -> String {
4874 roder_api::catalog::PROVIDER_MOCK.to_string()
4875 }
4876
4877 fn capabilities(&self) -> InferenceCapabilities {
4878 InferenceCapabilities::coding_agent_default()
4879 }
4880
4881 async fn list_models(
4882 &self,
4883 _ctx: InferenceProviderContext<'_>,
4884 ) -> anyhow::Result<Vec<roder_api::inference::ModelDescriptor>> {
4885 Ok(roder_api::catalog::models_for_provider(
4886 roder_api::catalog::PROVIDER_MOCK,
4887 true,
4888 ))
4889 }
4890
4891 async fn stream_turn(
4892 &self,
4893 _ctx: InferenceTurnContext<'_>,
4894 request: AgentInferenceRequest,
4895 ) -> anyhow::Result<InferenceEventStream> {
4896 *self.request.lock().unwrap() = Some(request);
4897 Ok(Box::pin(stream::iter(vec![
4898 Ok(InferenceEvent::MessageDelta(MessageDelta {
4899 text: "done".to_string(),
4900 phase: None,
4901 })),
4902 Ok(InferenceEvent::Completed(CompletionMetadata {
4903 stop_reason: Some("stop".to_string()),
4904 provider_response_id: None,
4905 })),
4906 ])))
4907 }
4908 }
4909
4910 struct RoutingCaptureEngine {
4911 id: &'static str,
4912 models: Vec<ModelDescriptor>,
4913 requests: Arc<StdMutex<Vec<AgentInferenceRequest>>>,
4914 }
4915
4916 #[async_trait::async_trait]
4917 impl InferenceEngine for RoutingCaptureEngine {
4918 fn id(&self) -> String {
4919 self.id.to_string()
4920 }
4921
4922 fn capabilities(&self) -> InferenceCapabilities {
4923 InferenceCapabilities::coding_agent_default()
4924 }
4925
4926 async fn list_models(
4927 &self,
4928 _ctx: InferenceProviderContext<'_>,
4929 ) -> anyhow::Result<Vec<ModelDescriptor>> {
4930 Ok(self.models.clone())
4931 }
4932
4933 async fn stream_turn(
4934 &self,
4935 _ctx: InferenceTurnContext<'_>,
4936 request: AgentInferenceRequest,
4937 ) -> anyhow::Result<InferenceEventStream> {
4938 self.requests.lock().unwrap().push(request);
4939 Ok(Box::pin(stream::iter(vec![
4940 Ok(InferenceEvent::MessageDelta(MessageDelta {
4941 text: "routed".to_string(),
4942 phase: None,
4943 })),
4944 Ok(InferenceEvent::Completed(CompletionMetadata {
4945 stop_reason: Some("stop".to_string()),
4946 provider_response_id: None,
4947 })),
4948 ])))
4949 }
4950 }
4951
4952 struct StaticRouter {
4953 id: &'static str,
4954 decision: InferenceRoutingDecision,
4955 contexts: Arc<StdMutex<Vec<InferenceRoutingContext>>>,
4956 }
4957
4958 #[async_trait::async_trait]
4959 impl InferenceRouter for StaticRouter {
4960 fn id(&self) -> String {
4961 self.id.to_string()
4962 }
4963
4964 async fn route(
4965 &self,
4966 context: InferenceRoutingContext,
4967 ) -> anyhow::Result<InferenceRoutingDecision> {
4968 self.contexts.lock().unwrap().push(context);
4969 Ok(self.decision.clone())
4970 }
4971 }
4972
4973 fn routing_test_model(id: &str, supported_reasoning: &[&str]) -> ModelDescriptor {
4974 ModelDescriptor {
4975 id: id.to_string(),
4976 name: id.to_string(),
4977 context_window: Some(128_000),
4978 default_reasoning: supported_reasoning
4979 .first()
4980 .map(|effort| (*effort).to_string()),
4981 supported_reasoning: supported_reasoning
4982 .iter()
4983 .map(|effort| ReasoningEffortDescriptor {
4984 effort: (*effort).to_string(),
4985 description: format!("{effort} reasoning"),
4986 })
4987 .collect(),
4988 }
4989 }
4990
4991 struct TaskLedgerCompletionGateEngine {
4992 calls: StdMutex<u32>,
4993 requests: Arc<StdMutex<Vec<AgentInferenceRequest>>>,
4994 }
4995
4996 #[async_trait::async_trait]
4997 impl InferenceEngine for TaskLedgerCompletionGateEngine {
4998 fn id(&self) -> String {
4999 roder_api::catalog::PROVIDER_MOCK.to_string()
5000 }
5001
5002 fn capabilities(&self) -> InferenceCapabilities {
5003 InferenceCapabilities::coding_agent_default()
5004 }
5005
5006 async fn list_models(
5007 &self,
5008 _ctx: InferenceProviderContext<'_>,
5009 ) -> anyhow::Result<Vec<roder_api::inference::ModelDescriptor>> {
5010 Ok(roder_api::catalog::models_for_provider(
5011 roder_api::catalog::PROVIDER_MOCK,
5012 true,
5013 ))
5014 }
5015
5016 async fn stream_turn(
5017 &self,
5018 _ctx: InferenceTurnContext<'_>,
5019 request: AgentInferenceRequest,
5020 ) -> anyhow::Result<InferenceEventStream> {
5021 self.requests.lock().unwrap().push(request);
5022 let mut calls = self.calls.lock().unwrap();
5023 *calls += 1;
5024 let events = match *calls {
5025 1 => vec![Ok(InferenceEvent::ToolCallCompleted(ToolCallCompleted {
5026 id: "ledger-open".to_string(),
5027 name: TASK_LEDGER_TOOL_NAME.to_string(),
5028 arguments: serde_json::json!({
5029 "tasks": [
5030 {
5031 "id": "inspect",
5032 "content": "Inspect local assets",
5033 "status": "completed",
5034 "evidence": "listed workspace"
5035 },
5036 {
5037 "id": "write",
5038 "content": "Write /app/result.txt",
5039 "status": "pending"
5040 }
5041 ],
5042 "requireCompletionEvidence": true
5043 })
5044 .to_string(),
5045 }))],
5046 3 => vec![Ok(InferenceEvent::ToolCallCompleted(ToolCallCompleted {
5047 id: "ledger-complete".to_string(),
5048 name: TASK_LEDGER_TOOL_NAME.to_string(),
5049 arguments: serde_json::json!({
5050 "tasks": [
5051 {
5052 "id": "inspect",
5053 "content": "Inspect local assets",
5054 "status": "completed",
5055 "evidence": "listed workspace"
5056 },
5057 {
5058 "id": "write",
5059 "content": "Write /app/result.txt",
5060 "status": "completed",
5061 "evidence": "wrote answer"
5062 }
5063 ],
5064 "requireCompletionEvidence": true
5065 })
5066 .to_string(),
5067 }))],
5068 _ => vec![Ok(InferenceEvent::MessageDelta(MessageDelta {
5069 text: "final".to_string(),
5070 phase: None,
5071 }))],
5072 };
5073 Ok(Box::pin(stream::iter(events.into_iter().chain(
5074 std::iter::once(Ok(InferenceEvent::Completed(CompletionMetadata {
5075 stop_reason: Some("stop".to_string()),
5076 provider_response_id: None,
5077 }))),
5078 ))))
5079 }
5080 }
5081
5082 struct VerificationGateEngine {
5083 calls: StdMutex<u32>,
5084 }
5085
5086 #[async_trait::async_trait]
5087 impl InferenceEngine for VerificationGateEngine {
5088 fn id(&self) -> String {
5089 roder_api::catalog::PROVIDER_MOCK.to_string()
5090 }
5091
5092 fn capabilities(&self) -> InferenceCapabilities {
5093 InferenceCapabilities::coding_agent_default()
5094 }
5095
5096 async fn list_models(
5097 &self,
5098 _ctx: InferenceProviderContext<'_>,
5099 ) -> anyhow::Result<Vec<roder_api::inference::ModelDescriptor>> {
5100 Ok(roder_api::catalog::models_for_provider(
5101 roder_api::catalog::PROVIDER_MOCK,
5102 true,
5103 ))
5104 }
5105
5106 async fn stream_turn(
5107 &self,
5108 _ctx: InferenceTurnContext<'_>,
5109 request: AgentInferenceRequest,
5110 ) -> anyhow::Result<InferenceEventStream> {
5111 let mut calls = self.calls.lock().unwrap();
5112 *calls += 1;
5113 let events = match *calls {
5114 1 => vec![Ok(InferenceEvent::ToolCallCompleted(ToolCallCompleted {
5115 id: "write-1".to_string(),
5116 name: "write_file".to_string(),
5117 arguments: serde_json::json!({
5118 "path": "src/lib.rs",
5119 "content": "pub fn answer() -> u8 { 42 }\n"
5120 })
5121 .to_string(),
5122 }))],
5123 2 => vec![Ok(InferenceEvent::MessageDelta(MessageDelta {
5124 text: "done too early".to_string(),
5125 phase: None,
5126 }))],
5127 3 if request.transcript.iter().any(|item| {
5128 matches!(
5129 item,
5130 TranscriptItem::UserMessage(message)
5131 if message.text.contains("Verification gate blocked final completion")
5132 )
5133 }) =>
5134 {
5135 vec![Ok(InferenceEvent::ToolCallCompleted(ToolCallCompleted {
5136 id: "verify-1".to_string(),
5137 name: crate::verification_gate::VERIFICATION_TOOL_NAME.to_string(),
5138 arguments: serde_json::json!({
5139 "originalTask": "write code",
5140 "changedFiles": ["src/lib.rs"],
5141 "toolEvidence": ["write_file wrote src/lib.rs"],
5142 "testsRun": ["cargo test -p roder-core verification_gate"],
5143 "openGaps": [],
5144 "status": "completed"
5145 })
5146 .to_string(),
5147 }))]
5148 }
5149 _ => vec![Ok(InferenceEvent::MessageDelta(MessageDelta {
5150 text: "verified final".to_string(),
5151 phase: None,
5152 }))],
5153 };
5154 Ok(Box::pin(stream::iter(events.into_iter().chain(
5155 std::iter::once(Ok(InferenceEvent::Completed(CompletionMetadata {
5156 stop_reason: Some("stop".to_string()),
5157 provider_response_id: None,
5158 }))),
5159 ))))
5160 }
5161 }
5162
5163 struct SpeedPolicyEngine {
5164 calls: StdMutex<u32>,
5165 requests: Arc<StdMutex<Vec<AgentInferenceRequest>>>,
5166 }
5167
5168 #[async_trait::async_trait]
5169 impl InferenceEngine for SpeedPolicyEngine {
5170 fn id(&self) -> String {
5171 roder_api::catalog::PROVIDER_MOCK.to_string()
5172 }
5173
5174 fn capabilities(&self) -> InferenceCapabilities {
5175 InferenceCapabilities::coding_agent_default()
5176 }
5177
5178 async fn list_models(
5179 &self,
5180 _ctx: InferenceProviderContext<'_>,
5181 ) -> anyhow::Result<Vec<roder_api::inference::ModelDescriptor>> {
5182 Ok(roder_api::catalog::models_for_provider(
5183 roder_api::catalog::PROVIDER_MOCK,
5184 true,
5185 ))
5186 }
5187
5188 async fn stream_turn(
5189 &self,
5190 _ctx: InferenceTurnContext<'_>,
5191 request: AgentInferenceRequest,
5192 ) -> anyhow::Result<InferenceEventStream> {
5193 self.requests.lock().unwrap().push(request.clone());
5194 let mut calls = self.calls.lock().unwrap();
5195 *calls += 1;
5196 let events = match *calls {
5197 1 => vec![Ok(InferenceEvent::ToolCallCompleted(ToolCallCompleted {
5198 id: "write-1".to_string(),
5199 name: "write_file".to_string(),
5200 arguments: serde_json::json!({
5201 "path": "src/lib.rs",
5202 "content": "pub fn answer() -> u8 { 42 }\n"
5203 })
5204 .to_string(),
5205 }))],
5206 3 if request.transcript.iter().any(|item| {
5207 matches!(
5208 item,
5209 TranscriptItem::UserMessage(message)
5210 if message.text.contains("Verification gate blocked final completion")
5211 )
5212 }) =>
5213 {
5214 vec![Ok(InferenceEvent::ToolCallCompleted(ToolCallCompleted {
5215 id: "verify-1".to_string(),
5216 name: crate::verification_gate::VERIFICATION_TOOL_NAME.to_string(),
5217 arguments: serde_json::json!({
5218 "originalTask": "write code",
5219 "changedFiles": ["src/lib.rs"],
5220 "toolEvidence": ["write_file wrote src/lib.rs"],
5221 "testsRun": ["cargo test -p roder-core speed_policy"],
5222 "openGaps": [],
5223 "status": "completed"
5224 })
5225 .to_string(),
5226 }))]
5227 }
5228 _ => vec![Ok(InferenceEvent::MessageDelta(MessageDelta {
5229 text: "done".to_string(),
5230 phase: None,
5231 }))],
5232 };
5233 Ok(Box::pin(stream::iter(events.into_iter().chain(
5234 std::iter::once(Ok(InferenceEvent::Completed(CompletionMetadata {
5235 stop_reason: Some("stop".to_string()),
5236 provider_response_id: None,
5237 }))),
5238 ))))
5239 }
5240 }
5241
5242 struct SwitchCaptureEngine {
5243 requests: Arc<StdMutex<Vec<AgentInferenceRequest>>>,
5244 }
5245
5246 #[async_trait::async_trait]
5247 impl InferenceEngine for SwitchCaptureEngine {
5248 fn id(&self) -> String {
5249 roder_api::catalog::PROVIDER_MOCK.to_string()
5250 }
5251
5252 fn capabilities(&self) -> InferenceCapabilities {
5253 InferenceCapabilities::coding_agent_default()
5254 }
5255
5256 async fn list_models(
5257 &self,
5258 _ctx: InferenceProviderContext<'_>,
5259 ) -> anyhow::Result<Vec<roder_api::inference::ModelDescriptor>> {
5260 Ok(roder_api::catalog::models_for_provider(
5261 roder_api::catalog::PROVIDER_MOCK,
5262 true,
5263 ))
5264 }
5265
5266 async fn stream_turn(
5267 &self,
5268 _ctx: InferenceTurnContext<'_>,
5269 request: AgentInferenceRequest,
5270 ) -> anyhow::Result<InferenceEventStream> {
5271 self.requests.lock().unwrap().push(request);
5272 Ok(Box::pin(stream::iter(vec![
5273 Ok(InferenceEvent::MessageDelta(MessageDelta {
5274 text: "done".to_string(),
5275 phase: None,
5276 })),
5277 Ok(InferenceEvent::Completed(CompletionMetadata {
5278 stop_reason: Some("stop".to_string()),
5279 provider_response_id: None,
5280 })),
5281 ])))
5282 }
5283 }
5284
5285 struct DeadlineEngine;
5286
5287 #[async_trait::async_trait]
5288 impl InferenceEngine for DeadlineEngine {
5289 fn id(&self) -> String {
5290 roder_api::catalog::PROVIDER_MOCK.to_string()
5291 }
5292
5293 fn capabilities(&self) -> InferenceCapabilities {
5294 InferenceCapabilities::coding_agent_default()
5295 }
5296
5297 async fn list_models(
5298 &self,
5299 _ctx: InferenceProviderContext<'_>,
5300 ) -> anyhow::Result<Vec<roder_api::inference::ModelDescriptor>> {
5301 Ok(Vec::new())
5302 }
5303
5304 async fn stream_turn(
5305 &self,
5306 _ctx: InferenceTurnContext<'_>,
5307 _request: AgentInferenceRequest,
5308 ) -> anyhow::Result<InferenceEventStream> {
5309 Ok(Box::pin(stream::once(async {
5310 tokio::time::sleep(std::time::Duration::from_secs(60)).await;
5311 Ok(InferenceEvent::MessageDelta(MessageDelta {
5312 text: "too late".to_string(),
5313 phase: None,
5314 }))
5315 })))
5316 }
5317 }
5318
5319 struct WriteFileContributor;
5320
5321 impl ToolContributor for WriteFileContributor {
5322 fn id(&self) -> String {
5323 "test-write".to_string()
5324 }
5325
5326 fn contribute(&self, registry: &mut ToolRegistry) -> anyhow::Result<()> {
5327 registry.register(Arc::new(WriteFileTool))
5328 }
5329 }
5330
5331 struct WriteFileTool;
5332
5333 #[async_trait::async_trait]
5334 impl ToolExecutor for WriteFileTool {
5335 fn spec(&self) -> ToolSpec {
5336 ToolSpec {
5337 name: "write_file".to_string(),
5338 description: "Write a test file.".to_string(),
5339 parameters: serde_json::json!({
5340 "type": "object",
5341 "properties": {
5342 "path": { "type": "string" },
5343 "content": { "type": "string" }
5344 },
5345 "required": ["path", "content"],
5346 "additionalProperties": false
5347 }),
5348 }
5349 }
5350
5351 async fn execute(
5352 &self,
5353 _ctx: ToolExecutionContext,
5354 call: ToolCall,
5355 ) -> anyhow::Result<ToolResult> {
5356 let path = call
5357 .arguments
5358 .get("path")
5359 .and_then(serde_json::Value::as_str)
5360 .unwrap_or("src/lib.rs");
5361 Ok(ToolResult {
5362 id: call.id,
5363 name: call.name,
5364 text: format!("wrote {path}"),
5365 data: serde_json::json!({ "path": path }),
5366 is_error: false,
5367 })
5368 }
5369 }
5370
5371 struct ProfileToolContributor;
5372
5373 impl ToolContributor for ProfileToolContributor {
5374 fn id(&self) -> String {
5375 "profile-tools".to_string()
5376 }
5377
5378 fn contribute(&self, registry: &mut ToolRegistry) -> anyhow::Result<()> {
5379 for name in ["apply_patch", "edit", "multi_edit", "write_file"] {
5380 registry.register(Arc::new(ProfileTool {
5381 name: name.to_string(),
5382 }))?;
5383 }
5384 Ok(())
5385 }
5386 }
5387
5388 struct ProfileTool {
5389 name: String,
5390 }
5391
5392 #[async_trait::async_trait]
5393 impl ToolExecutor for ProfileTool {
5394 fn spec(&self) -> ToolSpec {
5395 ToolSpec {
5396 name: self.name.clone(),
5397 description: format!("{} test tool", self.name),
5398 parameters: serde_json::json!({
5399 "type": "object",
5400 "properties": {
5401 "path": { "type": "string" },
5402 "content": { "type": "string" }
5403 },
5404 "required": ["path", "content"],
5405 "additionalProperties": false
5406 }),
5407 }
5408 }
5409
5410 async fn execute(
5411 &self,
5412 _ctx: ToolExecutionContext,
5413 call: ToolCall,
5414 ) -> anyhow::Result<ToolResult> {
5415 Ok(ToolResult {
5416 id: call.id,
5417 name: call.name,
5418 text: "ok".to_string(),
5419 data: serde_json::json!({}),
5420 is_error: false,
5421 })
5422 }
5423 }
5424
5425 fn test_model_profile(model: &str) -> ModelHarnessProfile {
5426 ModelHarnessProfile {
5427 model: model.to_string(),
5428 provider: roder_api::catalog::PROVIDER_OPENAI.to_string(),
5429 provider_family: ProviderFamily::OpenAi,
5430 edit_tool: Some(EDIT_TOOL_EDIT.to_string()),
5431 schema_policy: ModelSchemaPolicy::StandardRequiredFirst,
5432 instruction_overlay: ModelInstructionOverlay::IntuitiveContext,
5433 reasoning: ModelProfileReasoning {
5434 orientation: Some(REASONING_LOW.to_string()),
5435 execution: Some(REASONING_LOW.to_string()),
5436 verification: Some(REASONING_LOW.to_string()),
5437 recovery: Some(REASONING_LOW.to_string()),
5438 },
5439 parallel_tool_calls: Some(false),
5440 auto_compact_token_limit: Some(123_000),
5441 }
5442 }
5443
5444 async fn captured_profile_request(cfg: RuntimeConfig) -> AgentInferenceRequest {
5445 let captured = Arc::new(StdMutex::new(None));
5446 let mut builder = ExtensionRegistryBuilder::new();
5447 builder.inference_engine(Arc::new(CapturingEngine {
5448 request: captured.clone(),
5449 }));
5450 builder.tool_contributor(Arc::new(ProfileToolContributor));
5451 let runtime = Arc::new(Runtime::new(builder.build().unwrap(), cfg).unwrap());
5452 let mut rx = runtime.subscribe_events();
5453 let turn_id = runtime
5454 .start_turn(StartTurnRequest {
5455 thread_id: "thread-model-profile".to_string(),
5456 message: "use profile knobs".to_string(),
5457 images: Vec::new(),
5458 provider_override: None,
5459 model_override: None,
5460 reasoning_override: None,
5461 workspace: test_workspace(),
5462 instructions: InstructionBundle {
5463 system: None,
5464 developer: Some("base developer".to_string()),
5465 developer_context: None,
5466 },
5467 developer_context: None,
5468 task_ledger_required: false,
5469 })
5470 .await
5471 .unwrap();
5472
5473 tokio::time::timeout(std::time::Duration::from_secs(5), async {
5474 loop {
5475 let envelope = rx.recv().await.unwrap();
5476 if envelope.turn_id.as_deref() != Some(&turn_id) {
5477 continue;
5478 }
5479 match envelope.event {
5480 RoderEvent::TurnCompleted(_) => break,
5481 RoderEvent::TurnFailed(event) => panic!("turn failed: {}", event.error),
5482 _ => {}
5483 }
5484 }
5485 })
5486 .await
5487 .unwrap();
5488
5489 captured.lock().unwrap().clone().unwrap()
5490 }
5491
5492 struct ToolThenStopEngine {
5493 calls: StdMutex<u32>,
5494 }
5495
5496 #[async_trait::async_trait]
5497 impl InferenceEngine for ToolThenStopEngine {
5498 fn id(&self) -> String {
5499 roder_api::catalog::PROVIDER_MOCK.to_string()
5500 }
5501
5502 fn capabilities(&self) -> InferenceCapabilities {
5503 InferenceCapabilities::coding_agent_default()
5504 }
5505
5506 async fn list_models(
5507 &self,
5508 _ctx: InferenceProviderContext<'_>,
5509 ) -> anyhow::Result<Vec<roder_api::inference::ModelDescriptor>> {
5510 Ok(roder_api::catalog::models_for_provider(
5511 roder_api::catalog::PROVIDER_MOCK,
5512 true,
5513 ))
5514 }
5515
5516 async fn stream_turn(
5517 &self,
5518 _ctx: InferenceTurnContext<'_>,
5519 _request: AgentInferenceRequest,
5520 ) -> anyhow::Result<InferenceEventStream> {
5521 let mut calls = self.calls.lock().unwrap();
5522 *calls += 1;
5523 let events = match *calls {
5524 1 => vec![
5525 Ok(InferenceEvent::ToolCallCompleted(ToolCallCompleted {
5526 id: "write-1".to_string(),
5527 name: "write_file".to_string(),
5528 arguments: serde_json::json!({
5529 "path": "src/lib.rs",
5530 "content": "pub fn answer() -> u8 { 42 }\n"
5531 })
5532 .to_string(),
5533 })),
5534 Ok(InferenceEvent::Completed(CompletionMetadata {
5535 stop_reason: Some("tool_use".to_string()),
5536 provider_response_id: None,
5537 })),
5538 ],
5539 _ => vec![
5540 Ok(InferenceEvent::MessageDelta(MessageDelta {
5541 text: "final".to_string(),
5542 phase: None,
5543 })),
5544 Ok(InferenceEvent::Completed(CompletionMetadata {
5545 stop_reason: Some("end_turn".to_string()),
5546 provider_response_id: None,
5547 })),
5548 ],
5549 };
5550 Ok(Box::pin(stream::iter(events)))
5551 }
5552 }
5553
5554 #[tokio::test]
5555 async fn turn_completed_reports_terminal_step_finish_reason() {
5556 let mut builder = ExtensionRegistryBuilder::new();
5557 builder.inference_engine(Arc::new(ToolThenStopEngine {
5558 calls: StdMutex::new(0),
5559 }));
5560 builder.tool_contributor(Arc::new(WriteFileContributor));
5561 let runtime = Arc::new(
5562 Runtime::new(
5563 builder.build().unwrap(),
5564 RuntimeConfig {
5565 policy_mode: PolicyMode::Bypass,
5566 agent_swarm_mode: false,
5567 ..RuntimeConfig::default()
5568 },
5569 )
5570 .unwrap(),
5571 );
5572 let mut rx = runtime.subscribe_events();
5573 let turn_id = runtime
5574 .start_turn(StartTurnRequest {
5575 thread_id: "thread-finish-reason".to_string(),
5576 message: "write then finish".to_string(),
5577 images: Vec::new(),
5578 provider_override: None,
5579 model_override: None,
5580 reasoning_override: None,
5581 workspace: test_workspace(),
5582 instructions: InstructionBundle {
5583 system: None,
5584 developer: None,
5585 developer_context: None,
5586 },
5587 developer_context: None,
5588 task_ledger_required: false,
5589 })
5590 .await
5591 .unwrap();
5592
5593 let completed = tokio::time::timeout(std::time::Duration::from_secs(5), async {
5594 loop {
5595 let envelope = rx.recv().await.unwrap();
5596 if envelope.turn_id.as_deref() != Some(&turn_id) {
5597 continue;
5598 }
5599 match envelope.event {
5600 RoderEvent::TurnCompleted(event) => break event,
5601 RoderEvent::TurnFailed(event) => panic!("turn failed: {}", event.error),
5602 _ => {}
5603 }
5604 }
5605 })
5606 .await
5607 .unwrap();
5608
5609 assert_eq!(completed.finish_reason.as_deref(), Some("stop"));
5612 }
5613
5614 #[tokio::test]
5615 async fn inference_router_selection_changes_request_model_and_records_event() {
5616 let default_requests = Arc::new(StdMutex::new(Vec::<AgentInferenceRequest>::new()));
5617 let routed_requests = Arc::new(StdMutex::new(Vec::<AgentInferenceRequest>::new()));
5618 let contexts = Arc::new(StdMutex::new(Vec::<InferenceRoutingContext>::new()));
5619 let selected = ModelSelection {
5620 provider: "routed-provider".to_string(),
5621 model: "routed-model".to_string(),
5622 };
5623 let default = ModelSelection {
5624 provider: roder_api::catalog::PROVIDER_MOCK.to_string(),
5625 model: "mock".to_string(),
5626 };
5627 let decision = InferenceRoutingDecision {
5628 reasoning: Some(ReasoningConfig {
5629 enabled: true,
5630 level: Some(REASONING_LOW.to_string()),
5631 }),
5632 confidence: Some(0.91),
5633 baseline: Some(default.clone()),
5634 matched_signals: vec![roder_api::inference_routing::InferenceRoutingSignal::new(
5635 "intent", "routine",
5636 )],
5637 ..InferenceRoutingDecision::selected("test-router", selected.clone(), "routine request")
5638 };
5639
5640 let mut builder = ExtensionRegistryBuilder::new();
5641 builder.inference_engine(Arc::new(RoutingCaptureEngine {
5642 id: roder_api::catalog::PROVIDER_MOCK,
5643 models: vec![routing_test_model("mock", &[REASONING_LOW])],
5644 requests: default_requests.clone(),
5645 }));
5646 builder.inference_engine(Arc::new(RoutingCaptureEngine {
5647 id: "routed-provider",
5648 models: vec![routing_test_model(
5649 "routed-model",
5650 &[REASONING_LOW, REASONING_MEDIUM],
5651 )],
5652 requests: routed_requests.clone(),
5653 }));
5654 builder.inference_router(Arc::new(StaticRouter {
5655 id: "test-router",
5656 decision,
5657 contexts: contexts.clone(),
5658 }));
5659 let thread_root =
5660 std::env::temp_dir().join(format!("roder-routing-auto-{}", uuid::Uuid::new_v4()));
5661 builder.thread_store_factory(Arc::new(JsonlThreadStoreFactory {
5662 base_path: thread_root.clone(),
5663 }));
5664 let runtime = Arc::new(
5665 Runtime::new(
5666 builder.build().unwrap(),
5667 RuntimeConfig {
5668 default_provider: default.provider.clone(),
5669 default_model: default.model.clone(),
5670 ..RuntimeConfig::default()
5671 },
5672 )
5673 .unwrap(),
5674 );
5675 let thread_id = runtime
5676 .create_thread_with(CreateThreadRequest {
5677 title: Some("Routing auto".to_string()),
5678 workspace: test_workspace(),
5679 workspace_id: None,
5680 root_id: None,
5681 provider: Some(default.provider.clone()),
5682 model: Some(default.model.clone()),
5683 tool_allowlist: Vec::new(),
5684 developer_instructions: None,
5685 external_tools: Vec::new(),
5686 selection_mode: Some(ModelSelectionMode::auto(
5687 "test-router:coding",
5688 "test-router",
5689 "Auto: Coding",
5690 default.clone(),
5691 Some("coding".to_string()),
5692 None,
5693 )),
5694 runner: None,
5695 })
5696 .await
5697 .unwrap()
5698 .thread_id;
5699 let mut rx = runtime.subscribe_events();
5700 let turn_id = runtime
5701 .start_turn(StartTurnRequest {
5702 thread_id: thread_id.clone(),
5703 message: "small cleanup".to_string(),
5704 images: Vec::new(),
5705 provider_override: None,
5706 model_override: None,
5707 reasoning_override: None,
5708 workspace: test_workspace(),
5709 instructions: InstructionBundle::default(),
5710 developer_context: None,
5711 task_ledger_required: false,
5712 })
5713 .await
5714 .unwrap();
5715
5716 let mut routing_event = None;
5717 let mut inference_started = None;
5718 tokio::time::timeout(std::time::Duration::from_secs(5), async {
5719 loop {
5720 let envelope = rx.recv().await.unwrap();
5721 if envelope.turn_id.as_deref() != Some(&turn_id) {
5722 continue;
5723 }
5724 match envelope.event {
5725 RoderEvent::InferenceRoutingDecision(event) => {
5726 routing_event = Some(event);
5727 }
5728 RoderEvent::InferenceStarted(event) => {
5729 inference_started = Some(event);
5730 }
5731 RoderEvent::TurnCompleted(_) => break,
5732 RoderEvent::TurnFailed(event) => panic!("turn failed: {}", event.error),
5733 _ => {}
5734 }
5735 }
5736 })
5737 .await
5738 .unwrap();
5739
5740 assert!(default_requests.lock().unwrap().is_empty());
5741 let routed_requests = routed_requests.lock().unwrap();
5742 assert_eq!(routed_requests.len(), 1);
5743 assert_eq!(routed_requests[0].model, selected);
5744 assert_eq!(
5745 routed_requests[0].reasoning.level.as_deref(),
5746 Some(REASONING_LOW)
5747 );
5748 assert_eq!(
5749 routed_requests[0].metadata["inferenceRouting"]["outcome"],
5750 "selected"
5751 );
5752
5753 let routing_event = routing_event.expect("routing decision event");
5754 assert_eq!(routing_event.default_selection, default);
5755 assert_eq!(routing_event.selected_selection, selected);
5756 assert_eq!(
5757 routing_event.decision.outcome,
5758 InferenceRoutingOutcome::Selected
5759 );
5760 assert_eq!(
5761 inference_started.expect("inference started event").model,
5762 selected
5763 );
5764
5765 let contexts = contexts.lock().unwrap();
5766 assert_eq!(contexts.len(), 1);
5767 assert_eq!(contexts[0].default_selection, default);
5768 assert_eq!(contexts[0].candidates.len(), 2);
5769 assert!(
5770 contexts[0]
5771 .signals
5772 .iter()
5773 .any(|signal| signal.key == "profile" && signal.value == "coding")
5774 );
5775 let _ = std::fs::remove_dir_all(thread_root);
5776 }
5777
5778 #[tokio::test]
5779 async fn inference_router_is_bypassed_for_explicit_selection() {
5780 let requests = Arc::new(StdMutex::new(Vec::<AgentInferenceRequest>::new()));
5781 let contexts = Arc::new(StdMutex::new(Vec::<InferenceRoutingContext>::new()));
5782 let selected = ModelSelection {
5783 provider: roder_api::catalog::PROVIDER_MOCK.to_string(),
5784 model: "mock".to_string(),
5785 };
5786
5787 let mut builder = ExtensionRegistryBuilder::new();
5788 builder.inference_engine(Arc::new(RoutingCaptureEngine {
5789 id: roder_api::catalog::PROVIDER_MOCK,
5790 models: vec![routing_test_model("mock", &[REASONING_LOW])],
5791 requests: requests.clone(),
5792 }));
5793 builder.inference_router(Arc::new(StaticRouter {
5794 id: "test-router",
5795 decision: InferenceRoutingDecision::selected(
5796 "test-router",
5797 ModelSelection {
5798 provider: "missing".to_string(),
5799 model: "missing".to_string(),
5800 },
5801 "would route if called",
5802 ),
5803 contexts: contexts.clone(),
5804 }));
5805 let thread_root =
5806 std::env::temp_dir().join(format!("roder-routing-explicit-{}", uuid::Uuid::new_v4()));
5807 builder.thread_store_factory(Arc::new(JsonlThreadStoreFactory {
5808 base_path: thread_root.clone(),
5809 }));
5810 let runtime = Arc::new(
5811 Runtime::new(
5812 builder.build().unwrap(),
5813 RuntimeConfig {
5814 default_provider: selected.provider.clone(),
5815 default_model: selected.model.clone(),
5816 inference_router: RuntimeInferenceRouterConfig {
5817 enabled: true,
5818 router_id: Some("test-router".to_string()),
5819 },
5820 ..RuntimeConfig::default()
5821 },
5822 )
5823 .unwrap(),
5824 );
5825 let thread_id = runtime
5826 .create_thread_with(CreateThreadRequest {
5827 title: Some("Routing explicit".to_string()),
5828 workspace: test_workspace(),
5829 workspace_id: None,
5830 root_id: None,
5831 provider: Some(selected.provider.clone()),
5832 model: Some(selected.model.clone()),
5833 tool_allowlist: Vec::new(),
5834 developer_instructions: None,
5835 external_tools: Vec::new(),
5836 selection_mode: Some(ModelSelectionMode::auto(
5837 "test-router:default",
5838 "test-router",
5839 "Auto",
5840 selected.clone(),
5841 None,
5842 None,
5843 )),
5844 runner: None,
5845 })
5846 .await
5847 .unwrap()
5848 .thread_id;
5849 let mut rx = runtime.subscribe_events();
5850 let turn_id = runtime
5851 .start_turn(StartTurnRequest {
5852 thread_id,
5853 message: "use explicit selection".to_string(),
5854 images: Vec::new(),
5855 provider_override: Some(selected.provider.clone()),
5856 model_override: Some(selected.model.clone()),
5857 reasoning_override: None,
5858 workspace: test_workspace(),
5859 instructions: InstructionBundle::default(),
5860 developer_context: None,
5861 task_ledger_required: false,
5862 })
5863 .await
5864 .unwrap();
5865
5866 let mut saw_routing_event = false;
5867 tokio::time::timeout(std::time::Duration::from_secs(5), async {
5868 loop {
5869 let envelope = rx.recv().await.unwrap();
5870 if envelope.turn_id.as_deref() != Some(&turn_id) {
5871 continue;
5872 }
5873 match envelope.event {
5874 RoderEvent::InferenceRoutingDecision(_) => {
5875 saw_routing_event = true;
5876 }
5877 RoderEvent::TurnCompleted(_) => break,
5878 RoderEvent::TurnFailed(event) => panic!("turn failed: {}", event.error),
5879 _ => {}
5880 }
5881 }
5882 })
5883 .await
5884 .unwrap();
5885
5886 assert!(!saw_routing_event);
5887 assert!(contexts.lock().unwrap().is_empty());
5888 let requests = requests.lock().unwrap();
5889 assert_eq!(requests.len(), 1);
5890 assert_eq!(requests[0].model, selected);
5891 let _ = std::fs::remove_dir_all(thread_root);
5892 }
5893
5894 #[tokio::test]
5895 async fn inference_router_is_bypassed_for_manual_selection_mode() {
5896 let requests = Arc::new(StdMutex::new(Vec::<AgentInferenceRequest>::new()));
5897 let contexts = Arc::new(StdMutex::new(Vec::<InferenceRoutingContext>::new()));
5898 let selected = ModelSelection {
5899 provider: roder_api::catalog::PROVIDER_MOCK.to_string(),
5900 model: "mock".to_string(),
5901 };
5902
5903 let mut builder = ExtensionRegistryBuilder::new();
5904 builder.inference_engine(Arc::new(RoutingCaptureEngine {
5905 id: roder_api::catalog::PROVIDER_MOCK,
5906 models: vec![routing_test_model("mock", &[REASONING_LOW])],
5907 requests: requests.clone(),
5908 }));
5909 builder.inference_router(Arc::new(StaticRouter {
5910 id: "test-router",
5911 decision: InferenceRoutingDecision::selected(
5912 "test-router",
5913 ModelSelection {
5914 provider: "missing".to_string(),
5915 model: "missing".to_string(),
5916 },
5917 "would route if called",
5918 ),
5919 contexts: contexts.clone(),
5920 }));
5921 let thread_root =
5922 std::env::temp_dir().join(format!("roder-routing-manual-{}", uuid::Uuid::new_v4()));
5923 builder.thread_store_factory(Arc::new(JsonlThreadStoreFactory {
5924 base_path: thread_root.clone(),
5925 }));
5926 let runtime = Arc::new(
5927 Runtime::new(
5928 builder.build().unwrap(),
5929 RuntimeConfig {
5930 default_provider: selected.provider.clone(),
5931 default_model: selected.model.clone(),
5932 inference_router: RuntimeInferenceRouterConfig {
5933 enabled: true,
5934 router_id: Some("test-router".to_string()),
5935 },
5936 ..RuntimeConfig::default()
5937 },
5938 )
5939 .unwrap(),
5940 );
5941 let thread_id = runtime
5942 .create_thread_with(CreateThreadRequest {
5943 title: Some("Routing manual".to_string()),
5944 workspace: test_workspace(),
5945 workspace_id: None,
5946 root_id: None,
5947 provider: Some(selected.provider.clone()),
5948 model: Some(selected.model.clone()),
5949 tool_allowlist: Vec::new(),
5950 developer_instructions: None,
5951 external_tools: Vec::new(),
5952 selection_mode: Some(ModelSelectionMode::manual(
5953 selected.provider.clone(),
5954 selected.model.clone(),
5955 None,
5956 )),
5957 runner: None,
5958 })
5959 .await
5960 .unwrap()
5961 .thread_id;
5962 let mut rx = runtime.subscribe_events();
5963 let turn_id = runtime
5964 .start_turn(StartTurnRequest {
5965 thread_id,
5966 message: "use selected manual model".to_string(),
5967 images: Vec::new(),
5968 provider_override: None,
5969 model_override: None,
5970 reasoning_override: None,
5971 workspace: test_workspace(),
5972 instructions: InstructionBundle::default(),
5973 developer_context: None,
5974 task_ledger_required: false,
5975 })
5976 .await
5977 .unwrap();
5978
5979 let mut saw_routing_event = false;
5980 tokio::time::timeout(std::time::Duration::from_secs(5), async {
5981 loop {
5982 let envelope = rx.recv().await.unwrap();
5983 if envelope.turn_id.as_deref() != Some(&turn_id) {
5984 continue;
5985 }
5986 match envelope.event {
5987 RoderEvent::InferenceRoutingDecision(_) => {
5988 saw_routing_event = true;
5989 }
5990 RoderEvent::TurnCompleted(_) => break,
5991 RoderEvent::TurnFailed(event) => panic!("turn failed: {}", event.error),
5992 _ => {}
5993 }
5994 }
5995 })
5996 .await
5997 .unwrap();
5998
5999 assert!(!saw_routing_event);
6000 assert!(contexts.lock().unwrap().is_empty());
6001 let requests = requests.lock().unwrap();
6002 assert_eq!(requests.len(), 1);
6003 assert_eq!(requests[0].model, selected);
6004 let _ = std::fs::remove_dir_all(thread_root);
6005 }
6006
6007 #[test]
6008 fn enabled_inference_router_requires_registered_router() {
6009 let mut builder = ExtensionRegistryBuilder::new();
6010 builder.inference_engine(Arc::new(FakeInferenceEngine));
6011
6012 let err = match Runtime::new(
6013 builder.build().unwrap(),
6014 RuntimeConfig {
6015 inference_router: RuntimeInferenceRouterConfig {
6016 enabled: true,
6017 router_id: Some("missing-router".to_string()),
6018 },
6019 ..RuntimeConfig::default()
6020 },
6021 ) {
6022 Ok(_) => panic!("runtime should reject unknown inference router"),
6023 Err(err) => err,
6024 };
6025
6026 assert!(
6027 err.to_string()
6028 .contains("inference router \"missing-router\" is not registered")
6029 );
6030 }
6031
6032 #[tokio::test]
6033 async fn model_profile_routes_request_knobs_to_next_inference() {
6034 let request = captured_profile_request(RuntimeConfig {
6035 default_provider: roder_api::catalog::PROVIDER_MOCK.to_string(),
6036 default_model: "gpt-5.5".to_string(),
6037 model_profiles: std::collections::HashMap::from([(
6038 "gpt-5.5".to_string(),
6039 test_model_profile("gpt-5.5"),
6040 )]),
6041 ..RuntimeConfig::default()
6042 })
6043 .await;
6044
6045 let tool_names = request
6046 .tools
6047 .iter()
6048 .map(|tool| tool.name.as_str())
6049 .collect::<Vec<_>>();
6050 assert!(tool_names.contains(&"apply_patch"));
6051 assert!(tool_names.contains(&"edit"));
6052 assert!(tool_names.contains(&"multi_edit"));
6053 assert!(tool_names.contains(&"write_file"));
6054 assert_eq!(request.reasoning.level.as_deref(), Some(REASONING_LOW));
6055 assert_eq!(request.runtime.parallel_tool_calls, Some(false));
6056 assert_eq!(request.runtime.auto_compact_token_limit, Some(123_000));
6057 assert!(
6058 request
6059 .instructions
6060 .developer
6061 .as_deref()
6062 .unwrap_or_default()
6063 .contains("Use the provided context as the current working set")
6064 );
6065 assert_eq!(
6066 request
6067 .metadata
6068 .pointer("/modelProfile/schemaPolicy")
6069 .and_then(serde_json::Value::as_str),
6070 Some("standard_required_first")
6071 );
6072 }
6073
6074 #[tokio::test]
6075 async fn turn_developer_context_reaches_inference_and_does_not_persist() {
6076 let captured = Arc::new(StdMutex::new(None));
6077 let mut builder = ExtensionRegistryBuilder::new();
6078 builder.inference_engine(Arc::new(CapturingEngine {
6079 request: captured.clone(),
6080 }));
6081 let runtime = Arc::new(
6082 Runtime::new(
6083 builder.build().unwrap(),
6084 RuntimeConfig {
6085 default_provider: roder_api::catalog::PROVIDER_MOCK.to_string(),
6086 default_model: "gpt-5.5".to_string(),
6087 ..RuntimeConfig::default()
6088 },
6089 )
6090 .unwrap(),
6091 );
6092
6093 async fn run_turn(runtime: &Arc<Runtime>, developer_context: Option<String>) {
6094 let mut rx = runtime.subscribe_events();
6095 let turn_id = runtime
6096 .start_turn(StartTurnRequest {
6097 thread_id: "thread-turn-context".to_string(),
6098 message: "hello".to_string(),
6099 images: Vec::new(),
6100 provider_override: None,
6101 model_override: None,
6102 reasoning_override: None,
6103 workspace: test_workspace(),
6104 instructions: InstructionBundle::default(),
6105 developer_context,
6106 task_ledger_required: false,
6107 })
6108 .await
6109 .unwrap();
6110 tokio::time::timeout(std::time::Duration::from_secs(5), async {
6111 loop {
6112 let envelope = rx.recv().await.unwrap();
6113 if envelope.turn_id.as_deref() != Some(&turn_id) {
6114 continue;
6115 }
6116 match envelope.event {
6117 RoderEvent::TurnCompleted(_) => break,
6118 RoderEvent::TurnFailed(event) => panic!("turn failed: {}", event.error),
6119 _ => {}
6120 }
6121 }
6122 })
6123 .await
6124 .unwrap();
6125 }
6126
6127 run_turn(
6128 &runtime,
6129 Some("Connected accounts: example-service.".to_string()),
6130 )
6131 .await;
6132 let request = captured.lock().unwrap().clone().unwrap();
6133 assert_eq!(
6134 request.instructions.developer_context.as_deref(),
6135 Some("Connected accounts: example-service.")
6136 );
6137
6138 run_turn(&runtime, None).await;
6141 let request = captured.lock().unwrap().clone().unwrap();
6142 assert_eq!(request.instructions.developer_context, None);
6143 }
6144
6145 #[tokio::test]
6146 async fn tool_search_overrides_route_to_next_inference_request() {
6147 let request = captured_profile_request(RuntimeConfig {
6148 default_provider: roder_api::catalog::PROVIDER_MOCK.to_string(),
6149 default_model: "gpt-5.4".to_string(),
6150 tool_search: ToolSearchConfig {
6151 mode: roder_api::inference::ToolSearchMode::Auto,
6152 max_catalog_items: Some(100),
6153 ..ToolSearchConfig::default()
6154 },
6155 provider_tool_search: std::collections::HashMap::from([(
6156 roder_api::catalog::PROVIDER_MOCK.to_string(),
6157 roder_api::inference::ToolSearchConfigOverlay {
6158 include_skills: Some(false),
6159 provider_variant: Some(roder_api::inference::ToolSearchProviderVariant::Regex),
6160 ..Default::default()
6161 },
6162 )]),
6163 model_tool_search: std::collections::HashMap::from([(
6164 "gpt-5.4".to_string(),
6165 roder_api::inference::ToolSearchConfigOverlay {
6166 mode: Some(roder_api::inference::ToolSearchMode::ProviderNative),
6167 max_catalog_items: Some(25),
6168 provider_variant: Some(roder_api::inference::ToolSearchProviderVariant::Bm25),
6169 ..Default::default()
6170 },
6171 )]),
6172 ..RuntimeConfig::default()
6173 })
6174 .await;
6175
6176 assert_eq!(
6177 request.runtime.tool_search.mode,
6178 roder_api::inference::ToolSearchMode::ProviderNative
6179 );
6180 assert_eq!(request.runtime.tool_search.max_catalog_items, Some(25));
6181 assert_eq!(
6182 request.runtime.tool_search.provider_variant,
6183 roder_api::inference::ToolSearchProviderVariant::Bm25
6184 );
6185 }
6186
6187 #[tokio::test]
6188 async fn context_entrypoint_hints_use_turn_workspace() {
6189 let process_workspace = runtime_test_workspace("entrypoint-process");
6190 let thread_workspace = runtime_test_workspace("entrypoint-thread");
6191 std::fs::create_dir_all(process_workspace.join("src")).unwrap();
6192 std::fs::create_dir_all(thread_workspace.join("src")).unwrap();
6193 std::fs::write(
6194 process_workspace.join("src/sidebar-thread-groups.ts"),
6195 "export const desktopLeak = true;\n",
6196 )
6197 .unwrap();
6198 std::fs::write(
6199 thread_workspace.join("src/voice-plan-feedback.ts"),
6200 "export const voicePlanFeedback = true;\n",
6201 )
6202 .unwrap();
6203
6204 let captured = Arc::new(StdMutex::new(None));
6205 let mut builder = ExtensionRegistryBuilder::new();
6206 builder.inference_engine(Arc::new(CapturingEngine {
6207 request: captured.clone(),
6208 }));
6209 builder.context_planner(Arc::new(roder_context::EntrypointContextPlanner::new(
6210 process_workspace.clone(),
6211 )));
6212 let runtime = Arc::new(
6213 Runtime::new(
6214 builder.build().unwrap(),
6215 RuntimeConfig {
6216 workspace: Some(process_workspace.display().to_string()),
6217 ..RuntimeConfig::default()
6218 },
6219 )
6220 .unwrap(),
6221 );
6222 let mut rx = runtime.subscribe_events();
6223
6224 let turn_id = runtime
6225 .start_turn(StartTurnRequest {
6226 thread_id: "thread-workspace-entrypoint".to_string(),
6227 message: "investigate voice plan feedback".to_string(),
6228 images: Vec::new(),
6229 provider_override: None,
6230 model_override: None,
6231 reasoning_override: None,
6232 workspace: thread_workspace.display().to_string(),
6233 instructions: crate::instructions::default_instructions(),
6234 developer_context: None,
6235 task_ledger_required: false,
6236 })
6237 .await
6238 .unwrap();
6239
6240 tokio::time::timeout(std::time::Duration::from_secs(5), async {
6241 loop {
6242 let envelope = rx.recv().await.unwrap();
6243 if envelope.turn_id.as_deref() != Some(&turn_id) {
6244 continue;
6245 }
6246 match envelope.event {
6247 RoderEvent::TurnCompleted(_) => break,
6248 RoderEvent::TurnFailed(event) => panic!("turn failed: {}", event.error),
6249 _ => {}
6250 }
6251 }
6252 })
6253 .await
6254 .unwrap();
6255
6256 let request = captured.lock().unwrap().clone().expect("captured request");
6257 let transcript_text = request
6258 .transcript
6259 .iter()
6260 .map(|item| match item {
6261 TranscriptItem::UserMessage(message) => message.text.as_str(),
6262 _ => "",
6263 })
6264 .collect::<Vec<_>>()
6265 .join("\n");
6266 assert!(transcript_text.contains("src/voice-plan-feedback.ts"));
6267 assert!(!transcript_text.contains("src/sidebar-thread-groups.ts"));
6268
6269 let _ = std::fs::remove_dir_all(process_workspace);
6270 let _ = std::fs::remove_dir_all(thread_workspace);
6271 }
6272
6273 fn runtime_test_workspace(name: &str) -> std::path::PathBuf {
6274 let path =
6275 std::env::temp_dir().join(format!("roder-runtime-{name}-{}", uuid::Uuid::new_v4()));
6276 let _ = std::fs::remove_dir_all(&path);
6277 std::fs::create_dir_all(&path).unwrap();
6278 path
6279 }
6280
6281 #[tokio::test]
6282 async fn model_profile_user_model_knobs_override_profile_defaults() {
6283 let request = captured_profile_request(RuntimeConfig {
6284 default_provider: roder_api::catalog::PROVIDER_MOCK.to_string(),
6285 default_model: "gpt-5.5".to_string(),
6286 reasoning: Some(REASONING_HIGH.to_string()),
6287 auto_compact_token_limit: Some(999),
6288 model_edit_tools: std::collections::HashMap::from([(
6289 "gpt-5.5".to_string(),
6290 EDIT_TOOL_PATCH.to_string(),
6291 )]),
6292 model_parallel_tool_calls: std::collections::HashMap::from([(
6293 "gpt-5.5".to_string(),
6294 true,
6295 )]),
6296 model_profiles: std::collections::HashMap::from([(
6297 "gpt-5.5".to_string(),
6298 test_model_profile("gpt-5.5"),
6299 )]),
6300 ..RuntimeConfig::default()
6301 })
6302 .await;
6303
6304 let tool_names = request
6305 .tools
6306 .iter()
6307 .map(|tool| tool.name.as_str())
6308 .collect::<Vec<_>>();
6309 assert!(tool_names.contains(&"apply_patch"));
6310 assert!(!tool_names.contains(&"edit"));
6311 assert_eq!(request.reasoning.level.as_deref(), Some(REASONING_HIGH));
6312 assert_eq!(request.runtime.parallel_tool_calls, Some(true));
6313 assert_eq!(request.runtime.auto_compact_token_limit, Some(999));
6314 }
6315
6316 #[tokio::test]
6317 async fn runtime_tool_allowlist_filters_advertised_tools() {
6318 let request = captured_profile_request(RuntimeConfig {
6319 default_provider: roder_api::catalog::PROVIDER_MOCK.to_string(),
6320 default_model: "gpt-5.5".to_string(),
6321 tool_allowlist: vec!["edit".to_string()],
6322 model_profiles: std::collections::HashMap::from([(
6323 "gpt-5.5".to_string(),
6324 test_model_profile("gpt-5.5"),
6325 )]),
6326 ..RuntimeConfig::default()
6327 })
6328 .await;
6329
6330 let tool_names = request
6331 .tools
6332 .iter()
6333 .map(|tool| tool.name.as_str())
6334 .collect::<Vec<_>>();
6335 assert_eq!(tool_names, vec!["edit"]);
6336 }
6337
6338 async fn captured_thread_override_request(
6341 runtime: &Arc<Runtime>,
6342 requests: &Arc<StdMutex<Vec<AgentInferenceRequest>>>,
6343 tool_allowlist: Vec<String>,
6344 developer_instructions: Option<String>,
6345 external_tools: Vec<ToolSpec>,
6346 ) -> AgentInferenceRequest {
6347 let thread_id = runtime
6348 .create_thread_with(CreateThreadRequest {
6349 title: Some("Thread overrides".to_string()),
6350 workspace: test_workspace(),
6351 workspace_id: None,
6352 root_id: None,
6353 provider: None,
6354 model: None,
6355 selection_mode: None,
6356 tool_allowlist,
6357 developer_instructions,
6358 external_tools,
6359 runner: None,
6360 })
6361 .await
6362 .unwrap()
6363 .thread_id;
6364 let mut rx = runtime.subscribe_events();
6365 let turn_id = runtime
6366 .start_turn(StartTurnRequest {
6367 thread_id,
6368 message: "hello".to_string(),
6369 images: Vec::new(),
6370 provider_override: None,
6371 model_override: None,
6372 reasoning_override: None,
6373 workspace: test_workspace(),
6374 instructions: crate::default_instructions(),
6375 developer_context: None,
6376 task_ledger_required: false,
6377 })
6378 .await
6379 .unwrap();
6380 tokio::time::timeout(std::time::Duration::from_secs(5), async {
6381 loop {
6382 let envelope = rx.recv().await.unwrap();
6383 if envelope.turn_id.as_deref() != Some(&turn_id) {
6384 continue;
6385 }
6386 match envelope.event {
6387 RoderEvent::TurnCompleted(_) => break,
6388 RoderEvent::TurnFailed(event) => panic!("turn failed: {}", event.error),
6389 _ => {}
6390 }
6391 }
6392 })
6393 .await
6394 .unwrap();
6395 requests.lock().unwrap().pop().expect("captured request")
6396 }
6397
6398 #[tokio::test]
6399 async fn thread_tool_allowlist_filters_only_that_thread() {
6400 let requests = Arc::new(StdMutex::new(Vec::new()));
6401 let thread_root =
6402 std::env::temp_dir().join(format!("roder-thread-allowlist-{}", uuid::Uuid::new_v4()));
6403 let mut builder = ExtensionRegistryBuilder::new();
6404 builder.inference_engine(Arc::new(SwitchCaptureEngine {
6405 requests: requests.clone(),
6406 }));
6407 builder.thread_store_factory(Arc::new(JsonlThreadStoreFactory {
6408 base_path: thread_root.clone(),
6409 }));
6410 builder.tool_contributor(Arc::new(ProfileToolContributor));
6411 let runtime = Arc::new(
6412 Runtime::new(
6413 builder.build().unwrap(),
6414 RuntimeConfig {
6415 default_provider: roder_api::catalog::PROVIDER_MOCK.to_string(),
6416 default_model: "gpt-5.5".to_string(),
6417 model_profiles: std::collections::HashMap::from([(
6418 "gpt-5.5".to_string(),
6419 test_model_profile("gpt-5.5"),
6420 )]),
6421 ..RuntimeConfig::default()
6422 },
6423 )
6424 .unwrap(),
6425 );
6426
6427 let allowlisted = captured_thread_override_request(
6428 &runtime,
6429 &requests,
6430 vec!["edit".to_string()],
6431 None,
6432 Vec::new(),
6433 )
6434 .await;
6435 let unrestricted =
6436 captured_thread_override_request(&runtime, &requests, Vec::new(), None, Vec::new())
6437 .await;
6438
6439 let allowlisted_names = allowlisted
6440 .tools
6441 .iter()
6442 .map(|tool| tool.name.as_str())
6443 .collect::<Vec<_>>();
6444 assert_eq!(allowlisted_names, vec!["edit"]);
6445 let unrestricted_names = unrestricted
6446 .tools
6447 .iter()
6448 .map(|tool| tool.name.as_str())
6449 .collect::<Vec<_>>();
6450 assert!(unrestricted_names.contains(&"edit"));
6451 assert!(unrestricted_names.len() > 1);
6452
6453 let _ = std::fs::remove_dir_all(thread_root);
6454 }
6455
6456 fn runtime_with_edit_allowlist(
6458 requests: &Arc<StdMutex<Vec<AgentInferenceRequest>>>,
6459 thread_root: &std::path::Path,
6460 ) -> Arc<Runtime> {
6461 let mut builder = ExtensionRegistryBuilder::new();
6462 builder.inference_engine(Arc::new(SwitchCaptureEngine {
6463 requests: requests.clone(),
6464 }));
6465 builder.thread_store_factory(Arc::new(JsonlThreadStoreFactory {
6466 base_path: thread_root.to_path_buf(),
6467 }));
6468 builder.tool_contributor(Arc::new(ProfileToolContributor));
6469 Arc::new(
6470 Runtime::new(
6471 builder.build().unwrap(),
6472 RuntimeConfig {
6473 default_provider: roder_api::catalog::PROVIDER_MOCK.to_string(),
6474 default_model: "gpt-5.5".to_string(),
6475 tool_allowlist: vec!["edit".to_string()],
6476 model_profiles: std::collections::HashMap::from([(
6477 "gpt-5.5".to_string(),
6478 test_model_profile("gpt-5.5"),
6479 )]),
6480 ..RuntimeConfig::default()
6481 },
6482 )
6483 .unwrap(),
6484 )
6485 }
6486
6487 #[tokio::test]
6488 async fn runtime_and_thread_allowlists_intersect() {
6489 let requests = Arc::new(StdMutex::new(Vec::new()));
6490 let thread_root = std::env::temp_dir().join(format!(
6491 "roder-allowlist-intersect-{}",
6492 uuid::Uuid::new_v4()
6493 ));
6494 let runtime = runtime_with_edit_allowlist(&requests, &thread_root);
6495
6496 let request = captured_thread_override_request(
6497 &runtime,
6498 &requests,
6499 vec!["edit".to_string(), "write_file".to_string()],
6500 None,
6501 Vec::new(),
6502 )
6503 .await;
6504
6505 let names = request
6507 .tools
6508 .iter()
6509 .map(|tool| tool.name.as_str())
6510 .collect::<Vec<_>>();
6511 assert_eq!(names, vec!["edit"]);
6512
6513 let _ = std::fs::remove_dir_all(thread_root);
6514 }
6515
6516 #[tokio::test]
6517 async fn route_tool_call_denies_tools_outside_allowlists() {
6518 let requests = Arc::new(StdMutex::new(Vec::new()));
6519 let thread_root =
6520 std::env::temp_dir().join(format!("roder-allowlist-dispatch-{}", uuid::Uuid::new_v4()));
6521 let runtime = runtime_with_edit_allowlist(&requests, &thread_root);
6522 let thread_id = runtime
6523 .create_thread_with(CreateThreadRequest {
6524 title: Some("Dispatch allowlist".to_string()),
6525 workspace: test_workspace(),
6526 workspace_id: None,
6527 root_id: None,
6528 provider: None,
6529 model: None,
6530 selection_mode: None,
6531 tool_allowlist: vec!["edit".to_string(), "write_file".to_string()],
6532 developer_instructions: None,
6533 external_tools: Vec::new(),
6534 runner: None,
6535 })
6536 .await
6537 .unwrap()
6538 .thread_id;
6539
6540 let result = runtime
6542 .route_tool_call(
6543 &thread_id,
6544 &"turn-allowlist-dispatch".to_string(),
6545 roder_api::inference::ToolCallCompleted {
6546 id: "call-1".to_string(),
6547 name: "write_file".to_string(),
6548 arguments: r#"{"path":"a.txt","content":"hi"}"#.to_string(),
6549 },
6550 None,
6551 None,
6552 )
6553 .await
6554 .unwrap();
6555
6556 assert!(result.is_error);
6557 assert!(
6558 result
6559 .result
6560 .contains("not permitted by the tool allowlist"),
6561 "unexpected result: {}",
6562 result.result
6563 );
6564
6565 let _ = std::fs::remove_dir_all(thread_root);
6566 }
6567
6568 #[tokio::test]
6569 async fn route_tool_calls_denies_agent_swarm_mixed_with_other_tools() {
6570 let requests = Arc::new(StdMutex::new(Vec::new()));
6571 let thread_root =
6572 std::env::temp_dir().join(format!("roder-swarm-exclusive-{}", uuid::Uuid::new_v4()));
6573 let runtime = runtime_with_edit_allowlist(&requests, &thread_root);
6574
6575 let results = runtime
6578 .route_tool_calls(
6579 &"thread-swarm".to_string(),
6580 &"turn-swarm".to_string(),
6581 vec![
6582 roder_api::inference::ToolCallCompleted {
6583 id: "swarm-1".to_string(),
6584 name: "agent_swarm".to_string(),
6585 arguments: "{}".to_string(),
6586 },
6587 roder_api::inference::ToolCallCompleted {
6588 id: "read-1".to_string(),
6589 name: "read_file".to_string(),
6590 arguments: "{}".to_string(),
6591 },
6592 ],
6593 true,
6594 None,
6595 None,
6596 )
6597 .await
6598 .unwrap();
6599
6600 assert_eq!(results.len(), 2, "every tool_call_id must get a response");
6601 assert_eq!(results[0].id, "swarm-1");
6602 assert_eq!(results[1].id, "read-1");
6603 for result in &results {
6604 assert!(result.is_error);
6605 assert!(
6606 result.result.contains("only tool call"),
6607 "unexpected result: {}",
6608 result.result
6609 );
6610 }
6611
6612 let _ = std::fs::remove_dir_all(thread_root);
6613 }
6614
6615 #[tokio::test]
6616 async fn route_tool_calls_emits_agent_swarm_started_event() {
6617 let requests = Arc::new(StdMutex::new(Vec::new()));
6618 let thread_root =
6619 std::env::temp_dir().join(format!("roder-swarm-started-{}", uuid::Uuid::new_v4()));
6620 let runtime = runtime_with_edit_allowlist(&requests, &thread_root);
6621 let mut events = runtime.subscribe_events();
6622
6623 let _ = runtime
6626 .route_tool_calls(
6627 &"thread-swarm".to_string(),
6628 &"turn-swarm".to_string(),
6629 vec![roder_api::inference::ToolCallCompleted {
6630 id: "swarm-1".to_string(),
6631 name: "agent_swarm".to_string(),
6632 arguments: r#"{"description":"x","prompt_template":"Read {{item}}","items":["a.rs","b.rs"]}"#
6633 .to_string(),
6634 }],
6635 true,
6636 None,
6637 None,
6638 )
6639 .await
6640 .unwrap();
6641
6642 let mut started_child_count = None;
6643 for _ in 0..16 {
6644 let envelope = tokio::time::timeout(std::time::Duration::from_secs(2), events.recv())
6645 .await
6646 .unwrap()
6647 .unwrap();
6648 if let RoderEvent::AgentSwarmStarted(event) = envelope.event {
6649 started_child_count = Some(event.child_count);
6650 assert_eq!(event.tool_id, "swarm-1");
6651 break;
6652 }
6653 }
6654 assert_eq!(started_child_count, Some(2));
6655
6656 let _ = std::fs::remove_dir_all(thread_root);
6657 }
6658
6659 #[test]
6660 fn agent_swarm_child_count_sums_items_and_resumes() {
6661 assert_eq!(
6662 agent_swarm_child_count(r#"{"description":"x","items":["a","b","c"]}"#),
6663 3
6664 );
6665 assert_eq!(
6666 agent_swarm_child_count(
6667 r#"{"description":"x","items":["a"],"resume_agent_ids":{"id1":"continue"}}"#
6668 ),
6669 2
6670 );
6671 assert_eq!(agent_swarm_child_count("not json"), 0);
6673 }
6674
6675 #[test]
6676 fn parse_swarm_counts_reads_summary_with_omitted_buckets() {
6677 let text = "<agent_swarm_result>\n<summary>completed: 2, failed: 1</summary>\n</agent_swarm_result>";
6678 assert_eq!(parse_swarm_counts(text), Some((2, 1, 0)));
6679 let text =
6680 "<agent_swarm_result>\n<summary>completed: 0</summary>\n</agent_swarm_result>";
6681 assert_eq!(parse_swarm_counts(text), Some((0, 0, 0)));
6682 assert_eq!(parse_swarm_counts("just text"), None);
6684 }
6685
6686 struct SignalledFailureEngine {
6688 started: tokio::sync::mpsc::UnboundedSender<()>,
6689 proceed: Arc<tokio::sync::Notify>,
6690 }
6691
6692 #[async_trait::async_trait]
6693 impl InferenceEngine for SignalledFailureEngine {
6694 fn id(&self) -> String {
6695 roder_api::catalog::PROVIDER_MOCK.to_string()
6696 }
6697
6698 fn capabilities(&self) -> InferenceCapabilities {
6699 InferenceCapabilities::coding_agent_default()
6700 }
6701
6702 async fn list_models(
6703 &self,
6704 _ctx: InferenceProviderContext<'_>,
6705 ) -> anyhow::Result<Vec<roder_api::inference::ModelDescriptor>> {
6706 Ok(roder_api::catalog::models_for_provider(
6707 roder_api::catalog::PROVIDER_MOCK,
6708 true,
6709 ))
6710 }
6711
6712 async fn stream_turn(
6713 &self,
6714 _ctx: InferenceTurnContext<'_>,
6715 _request: AgentInferenceRequest,
6716 ) -> anyhow::Result<InferenceEventStream> {
6717 let _ = self.started.send(());
6718 self.proceed.notified().await;
6719 anyhow::bail!("engine failed mid-turn")
6720 }
6721 }
6722
6723 #[tokio::test]
6724 async fn failed_turn_sweeps_pending_external_tool_calls() {
6725 let (started_tx, mut started_rx) = tokio::sync::mpsc::unbounded_channel();
6726 let proceed = Arc::new(tokio::sync::Notify::new());
6727 let mut builder = ExtensionRegistryBuilder::new();
6728 builder.inference_engine(Arc::new(SignalledFailureEngine {
6729 started: started_tx,
6730 proceed: proceed.clone(),
6731 }));
6732 let runtime =
6733 Arc::new(Runtime::new(builder.build().unwrap(), RuntimeConfig::default()).unwrap());
6734 let mut rx = runtime.subscribe_events();
6735 let turn_id = runtime
6736 .start_turn(StartTurnRequest {
6737 thread_id: "thread-sweep".to_string(),
6738 message: "go".to_string(),
6739 images: Vec::new(),
6740 provider_override: None,
6741 model_override: None,
6742 reasoning_override: None,
6743 workspace: test_workspace(),
6744 instructions: InstructionBundle {
6745 system: None,
6746 developer: None,
6747 developer_context: None,
6748 },
6749 developer_context: None,
6750 task_ledger_required: false,
6751 })
6752 .await
6753 .unwrap();
6754 tokio::time::timeout(std::time::Duration::from_secs(5), started_rx.recv())
6755 .await
6756 .unwrap()
6757 .unwrap();
6758
6759 let (tx, _pending_rx) = oneshot::channel();
6760 runtime.pending_external_tool_calls.lock().await.insert(
6761 "exttool-sweep-test".to_string(),
6762 PendingExternalToolCall {
6763 thread_id: "thread-sweep".to_string(),
6764 turn_id: turn_id.clone(),
6765 tool_id: "call-1".to_string(),
6766 tool_name: "acme_lookup".to_string(),
6767 tx,
6768 },
6769 );
6770 proceed.notify_one();
6771
6772 let outcome = tokio::time::timeout(std::time::Duration::from_secs(5), async {
6773 loop {
6774 let envelope = rx.recv().await.unwrap();
6775 if let RoderEvent::ExternalToolCallResolved(event) = envelope.event
6776 && event.request_id == "exttool-sweep-test"
6777 {
6778 break event.outcome;
6779 }
6780 }
6781 })
6782 .await
6783 .expect("turn failure must resolve pending external tool calls");
6784 assert_eq!(outcome, ExternalToolCallOutcome::Cancelled);
6785 assert!(runtime.pending_external_tool_calls.lock().await.is_empty());
6786 }
6787
6788 #[tokio::test]
6789 async fn thread_developer_instructions_layer_under_harness_prompt() {
6790 let requests = Arc::new(StdMutex::new(Vec::new()));
6791 let thread_root = std::env::temp_dir().join(format!(
6792 "roder-thread-instructions-{}",
6793 uuid::Uuid::new_v4()
6794 ));
6795 let mut builder = ExtensionRegistryBuilder::new();
6796 builder.inference_engine(Arc::new(SwitchCaptureEngine {
6797 requests: requests.clone(),
6798 }));
6799 builder.thread_store_factory(Arc::new(JsonlThreadStoreFactory {
6800 base_path: thread_root.clone(),
6801 }));
6802 builder.tool_contributor(Arc::new(ProfileToolContributor));
6803 let runtime =
6804 Arc::new(Runtime::new(builder.build().unwrap(), RuntimeConfig::default()).unwrap());
6805
6806 let request = captured_thread_override_request(
6807 &runtime,
6808 &requests,
6809 Vec::new(),
6810 Some("You are embedded in a host app.".to_string()),
6811 Vec::new(),
6812 )
6813 .await;
6814
6815 let system = request.instructions.system.expect("system instructions");
6816 assert!(system.starts_with("You are Roder"));
6817 let developer = request
6818 .instructions
6819 .developer
6820 .expect("developer instructions");
6821 assert!(developer.starts_with("You are embedded in a host app."));
6822
6823 let plain =
6824 captured_thread_override_request(&runtime, &requests, Vec::new(), None, Vec::new())
6825 .await;
6826 assert_eq!(plain.instructions.developer, None);
6827
6828 let _ = std::fs::remove_dir_all(thread_root);
6829 }
6830
6831 #[tokio::test]
6832 async fn thread_external_tools_are_advertised_and_shadow_builtins() {
6833 let requests = Arc::new(StdMutex::new(Vec::new()));
6834 let thread_root = std::env::temp_dir().join(format!(
6835 "roder-thread-external-tools-{}",
6836 uuid::Uuid::new_v4()
6837 ));
6838 let mut builder = ExtensionRegistryBuilder::new();
6839 builder.inference_engine(Arc::new(SwitchCaptureEngine {
6840 requests: requests.clone(),
6841 }));
6842 builder.thread_store_factory(Arc::new(JsonlThreadStoreFactory {
6843 base_path: thread_root.clone(),
6844 }));
6845 builder.tool_contributor(Arc::new(ProfileToolContributor));
6846 let runtime =
6847 Arc::new(Runtime::new(builder.build().unwrap(), RuntimeConfig::default()).unwrap());
6848
6849 let external_tools = vec![
6850 ToolSpec {
6851 name: "acme_lookup".to_string(),
6852 description: "Look up Acme workspace state.".to_string(),
6853 parameters: serde_json::json!({
6854 "type": "object",
6855 "properties": { "query": { "type": "string" } },
6856 "required": ["query"]
6857 }),
6858 },
6859 ToolSpec {
6860 name: "edit".to_string(),
6861 description: "Host-managed edit.".to_string(),
6862 parameters: serde_json::json!({ "type": "object" }),
6863 },
6864 ];
6865 let request =
6866 captured_thread_override_request(&runtime, &requests, Vec::new(), None, external_tools)
6867 .await;
6868
6869 let acme = request
6870 .tools
6871 .iter()
6872 .find(|tool| tool.name == "acme_lookup")
6873 .expect("external tool advertised");
6874 assert_eq!(acme.description, "Look up Acme workspace state.");
6875 assert_eq!(acme.parameters["required"][0], "query");
6876 let edits = request
6877 .tools
6878 .iter()
6879 .filter(|tool| tool.name == "edit")
6880 .collect::<Vec<_>>();
6881 assert_eq!(edits.len(), 1, "external edit shadows the builtin");
6882 assert_eq!(edits[0].description, "Host-managed edit.");
6883
6884 let plain =
6885 captured_thread_override_request(&runtime, &requests, Vec::new(), None, Vec::new())
6886 .await;
6887 assert!(plain.tools.iter().all(|tool| tool.name != "acme_lookup"));
6888 let plain_edit = plain
6889 .tools
6890 .iter()
6891 .find(|tool| tool.name == "edit")
6892 .expect("builtin edit advertised on plain thread");
6893 assert_eq!(plain_edit.description, "edit test tool");
6894
6895 let _ = std::fs::remove_dir_all(thread_root);
6896 }
6897
6898 #[tokio::test]
6899 async fn model_switch_injects_summary_and_records_profile_segments() {
6900 let requests = Arc::new(StdMutex::new(Vec::new()));
6901 let thread_root = std::env::temp_dir().join(format!(
6902 "roder-model-switch-thread-{}",
6903 uuid::Uuid::new_v4()
6904 ));
6905 let mut builder = ExtensionRegistryBuilder::new();
6906 builder.inference_engine(Arc::new(SwitchCaptureEngine {
6907 requests: requests.clone(),
6908 }));
6909 builder.thread_store_factory(Arc::new(JsonlThreadStoreFactory {
6910 base_path: thread_root.clone(),
6911 }));
6912 builder.tool_contributor(Arc::new(ProfileToolContributor));
6913 let mut claude_profile = test_model_profile("claude-haiku-4-5-20251001");
6914 claude_profile.provider_family = ProviderFamily::Anthropic;
6915 claude_profile.edit_tool = Some(EDIT_TOOL_EDIT.to_string());
6916 let runtime = Arc::new(
6917 Runtime::new(
6918 builder.build().unwrap(),
6919 RuntimeConfig {
6920 default_provider: roder_api::catalog::PROVIDER_MOCK.to_string(),
6921 default_model: "gpt-5.5".to_string(),
6922 model_profiles: std::collections::HashMap::from([
6923 ("gpt-5.5".to_string(), test_model_profile("gpt-5.5")),
6924 ("claude-haiku-4-5-20251001".to_string(), claude_profile),
6925 ]),
6926 ..RuntimeConfig::default()
6927 },
6928 )
6929 .unwrap(),
6930 );
6931 let thread_id = runtime
6932 .create_thread_with(CreateThreadRequest {
6933 title: Some("Model switch".to_string()),
6934 workspace: test_workspace(),
6935 workspace_id: None,
6936 root_id: None,
6937 provider: None,
6938 model: None,
6939 selection_mode: None,
6940 tool_allowlist: Vec::new(),
6941 developer_instructions: None,
6942 external_tools: Vec::new(),
6943 runner: None,
6944 })
6945 .await
6946 .unwrap()
6947 .thread_id;
6948 let mut rx = runtime.subscribe_events();
6949 for (message, model_override) in [
6950 ("first turn", None),
6951 ("second turn", Some("claude-haiku-4-5-20251001".to_string())),
6952 ] {
6953 let turn_id = runtime
6954 .start_turn(StartTurnRequest {
6955 thread_id: thread_id.clone(),
6956 message: message.to_string(),
6957 images: Vec::new(),
6958 provider_override: None,
6959 model_override,
6960 reasoning_override: None,
6961 workspace: test_workspace(),
6962 instructions: InstructionBundle::default(),
6963 developer_context: None,
6964 task_ledger_required: false,
6965 })
6966 .await
6967 .unwrap();
6968 tokio::time::timeout(std::time::Duration::from_secs(5), async {
6969 loop {
6970 let envelope = rx.recv().await.unwrap();
6971 if envelope.turn_id.as_deref() != Some(&turn_id) {
6972 continue;
6973 }
6974 match envelope.event {
6975 RoderEvent::TurnCompleted(_) => break,
6976 RoderEvent::TurnFailed(event) => panic!("turn failed: {}", event.error),
6977 _ => {}
6978 }
6979 }
6980 })
6981 .await
6982 .unwrap();
6983 }
6984
6985 let captured = requests.lock().unwrap().clone();
6986 assert_eq!(captured.len(), 2);
6987 assert!(captured[1].transcript.iter().any(|item| {
6988 matches!(
6989 item,
6990 TranscriptItem::UserMessage(message)
6991 if message.text.starts_with(MODEL_SWITCH_SUMMARY_PREFIX)
6992 && message.text.contains("previous profile mock/gpt-5.5")
6993 && message.text.contains("Current profile mock/claude-haiku-4-5-20251001")
6994 && message.text.contains("Available tools now:")
6995 )
6996 }));
6997
6998 let snapshot = runtime
6999 .thread_store
7000 .as_ref()
7001 .unwrap()
7002 .load_thread(&thread_id)
7003 .await
7004 .unwrap()
7005 .unwrap();
7006 let trace_segments = snapshot
7007 .turns
7008 .iter()
7009 .flat_map(|turn| &turn.items)
7010 .filter(|item| {
7011 matches!(
7012 item,
7013 TranscriptItem::ProviderMetadata(value)
7014 if value.get("kind").and_then(serde_json::Value::as_str)
7015 == Some(MODEL_PROFILE_TRACE_KIND)
7016 && value.get("segment").and_then(serde_json::Value::as_str)
7017 == Some("assistant")
7018 )
7019 })
7020 .count();
7021 assert!(trace_segments >= 2);
7022 let _ = std::fs::remove_dir_all(thread_root);
7023 }
7024
7025 struct CountingTaskTool {
7026 calls: Arc<StdMutex<u32>>,
7027 }
7028
7029 #[async_trait::async_trait]
7030 impl ToolExecutor for CountingTaskTool {
7031 fn spec(&self) -> ToolSpec {
7032 ToolSpec {
7033 name: "task".to_string(),
7034 description: "Dispatch a test subagent.".to_string(),
7035 parameters: serde_json::json!({
7036 "type": "object",
7037 "properties": {
7038 "description": { "type": "string" },
7039 "prompt": { "type": "string" },
7040 "parent_deadline_seconds": { "type": "integer" }
7041 },
7042 "required": ["description", "prompt"],
7043 "additionalProperties": false
7044 }),
7045 }
7046 }
7047
7048 async fn execute(
7049 &self,
7050 _ctx: ToolExecutionContext,
7051 call: ToolCall,
7052 ) -> anyhow::Result<ToolResult> {
7053 *self.calls.lock().unwrap() += 1;
7054 Ok(ToolResult {
7055 id: call.id,
7056 name: call.name,
7057 text: "started child".to_string(),
7058 data: serde_json::json!({}),
7059 is_error: false,
7060 })
7061 }
7062 }
7063
7064 #[tokio::test]
7065 async fn runtime_profile_reaches_inference_request_and_turn_metadata() {
7066 let captured = Arc::new(StdMutex::new(None));
7067 let mut builder = ExtensionRegistryBuilder::new();
7068 builder.inference_engine(Arc::new(CapturingEngine {
7069 request: captured.clone(),
7070 }));
7071 let runtime = Arc::new(
7072 Runtime::new(
7073 builder.build().unwrap(),
7074 RuntimeConfig {
7075 runtime_profile: RuntimeProfile::NonInteractive,
7076 ..RuntimeConfig::default()
7077 },
7078 )
7079 .unwrap(),
7080 );
7081 let mut rx = runtime.subscribe_events();
7082 let turn_id = runtime
7083 .start_turn(StartTurnRequest {
7084 thread_id: "thread-profile".to_string(),
7085 message: "work unattended".to_string(),
7086 images: Vec::new(),
7087 provider_override: None,
7088 model_override: None,
7089 reasoning_override: None,
7090 workspace: test_workspace(),
7091 instructions: InstructionBundle {
7092 system: None,
7093 developer: Some("base developer".to_string()),
7094 developer_context: None,
7095 },
7096 developer_context: None,
7097 task_ledger_required: false,
7098 })
7099 .await
7100 .unwrap();
7101
7102 let mut observed_profile = None;
7103 tokio::time::timeout(std::time::Duration::from_secs(5), async {
7104 loop {
7105 let envelope = rx.recv().await.unwrap();
7106 if envelope.turn_id.as_deref() != Some(&turn_id) {
7107 continue;
7108 }
7109 match envelope.event {
7110 RoderEvent::TurnStarted(event) => {
7111 observed_profile = Some(event.runtime_profile);
7112 }
7113 RoderEvent::TurnCompleted(_) => break,
7114 RoderEvent::TurnFailed(event) => panic!("turn failed: {}", event.error),
7115 _ => {}
7116 }
7117 }
7118 })
7119 .await
7120 .unwrap();
7121
7122 assert_eq!(observed_profile, Some(RuntimeProfile::NonInteractive));
7123 let request = captured.lock().unwrap().clone().unwrap();
7124 assert_eq!(request.runtime.profile, RuntimeProfile::NonInteractive);
7125 let developer = request.instructions.developer.unwrap();
7126 assert!(developer.contains("base developer"));
7127 assert!(developer.contains("non-interactive profile"));
7128 }
7129
7130 #[tokio::test]
7131 async fn global_policy_mode_changes_do_not_create_runtime_thread_directory() {
7132 let workspace = runtime_test_workspace("global-policy-mode");
7133 let thread_root = workspace.join("threads");
7134 let mut builder = ExtensionRegistryBuilder::new();
7135 builder.inference_engine(Arc::new(FakeInferenceEngine));
7136 builder.thread_store_factory(Arc::new(JsonlThreadStoreFactory {
7137 base_path: thread_root.clone(),
7138 }));
7139 let runtime = Runtime::new(
7140 builder.build().unwrap(),
7141 RuntimeConfig {
7142 workspace: Some(workspace.display().to_string()),
7143 ..Default::default()
7144 },
7145 )
7146 .unwrap();
7147
7148 runtime
7149 .set_policy_mode(PolicyMode::AcceptAll, Some("test".to_string()))
7150 .await
7151 .unwrap();
7152
7153 assert!(!thread_root.join("runtime").exists());
7154 let _ = std::fs::remove_dir_all(workspace);
7155 }
7156
7157 #[tokio::test]
7158 async fn task_ledger_enforcement_injects_eval_reminder_before_work() {
7159 let captured = Arc::new(StdMutex::new(None));
7160 let mut builder = ExtensionRegistryBuilder::new();
7161 builder.inference_engine(Arc::new(CapturingEngine {
7162 request: captured.clone(),
7163 }));
7164 builder.tool_contributor(Arc::new(
7165 roder_ext_task_ledger::TaskLedgerToolContributor::default(),
7166 ));
7167 let runtime = Arc::new(
7168 Runtime::new(
7169 builder.build().unwrap(),
7170 RuntimeConfig {
7171 runtime_profile: RuntimeProfile::Eval,
7172 policy_mode: PolicyMode::Bypass,
7173 agent_swarm_mode: false,
7174 ..RuntimeConfig::default()
7175 },
7176 )
7177 .unwrap(),
7178 );
7179 let mut rx = runtime.subscribe_events();
7180 let turn_id = runtime
7181 .start_turn(StartTurnRequest {
7182 thread_id: "thread-ledger".to_string(),
7183 message: "decomposed work".to_string(),
7184 images: Vec::new(),
7185 provider_override: None,
7186 model_override: None,
7187 reasoning_override: None,
7188 workspace: test_workspace(),
7189 instructions: InstructionBundle::default(),
7190 developer_context: None,
7191 task_ledger_required: true,
7192 })
7193 .await
7194 .unwrap();
7195
7196 tokio::time::timeout(std::time::Duration::from_secs(5), async {
7197 loop {
7198 let envelope = rx.recv().await.unwrap();
7199 if envelope.turn_id.as_deref() == Some(&turn_id)
7200 && matches!(envelope.event, RoderEvent::TurnCompleted(_))
7201 {
7202 break;
7203 }
7204 }
7205 })
7206 .await
7207 .unwrap();
7208
7209 let request = captured.lock().unwrap().clone().unwrap();
7210 let developer = request.instructions.developer.unwrap();
7211 assert!(developer.contains("Task Ledger Required"));
7212 assert!(developer.contains("task_ledger.update"));
7213 let tool_names: Vec<_> = request
7214 .tools
7215 .iter()
7216 .map(|tool| tool.name.as_str())
7217 .collect();
7218 assert!(
7219 tool_names.contains(&TASK_LEDGER_TOOL_NAME),
7220 "tool names: {tool_names:?}"
7221 );
7222 assert_eq!(
7223 request.tool_choice,
7224 ToolChoice::Specific(TASK_LEDGER_TOOL_NAME.to_string())
7225 );
7226 assert_eq!(request.tools.len(), 1);
7227 assert_eq!(request.tools[0].name, TASK_LEDGER_TOOL_NAME);
7228 }
7229
7230 #[tokio::test]
7231 async fn eval_task_ledger_blocks_final_answer_until_open_items_are_completed() {
7232 let requests = Arc::new(StdMutex::new(Vec::new()));
7233 let mut builder = ExtensionRegistryBuilder::new();
7234 builder.inference_engine(Arc::new(TaskLedgerCompletionGateEngine {
7235 calls: StdMutex::new(0),
7236 requests: requests.clone(),
7237 }));
7238 builder.tool_contributor(Arc::new(
7239 roder_ext_task_ledger::TaskLedgerToolContributor::default(),
7240 ));
7241 let runtime = Arc::new(
7242 Runtime::new(
7243 builder.build().unwrap(),
7244 RuntimeConfig {
7245 runtime_profile: RuntimeProfile::Eval,
7246 policy_mode: PolicyMode::Bypass,
7247 agent_swarm_mode: false,
7248 ..RuntimeConfig::default()
7249 },
7250 )
7251 .unwrap(),
7252 );
7253 let mut rx = runtime.subscribe_events();
7254 let turn_id = runtime
7255 .start_turn(StartTurnRequest {
7256 thread_id: "thread-ledger-completion".to_string(),
7257 message: "write the answer file".to_string(),
7258 images: Vec::new(),
7259 provider_override: None,
7260 model_override: None,
7261 reasoning_override: None,
7262 workspace: test_workspace(),
7263 instructions: InstructionBundle::default(),
7264 developer_context: None,
7265 task_ledger_required: true,
7266 })
7267 .await
7268 .unwrap();
7269
7270 tokio::time::timeout(std::time::Duration::from_secs(5), async {
7271 loop {
7272 let envelope = rx.recv().await.unwrap();
7273 if envelope.turn_id.as_deref() != Some(&turn_id) {
7274 continue;
7275 }
7276 match envelope.event {
7277 RoderEvent::TurnCompleted(_) => break,
7278 RoderEvent::TurnFailed(event) => panic!("turn failed: {}", event.error),
7279 _ => {}
7280 }
7281 }
7282 })
7283 .await
7284 .unwrap();
7285
7286 let requests = requests.lock().unwrap().clone();
7287 assert_eq!(requests.len(), 4);
7288 assert!(requests[2].transcript.iter().any(|item| {
7289 matches!(
7290 item,
7291 TranscriptItem::UserMessage(message)
7292 if message.text.contains("Task Ledger Completion Required")
7293 && message.text.contains("Write /app/result.txt")
7294 )
7295 }));
7296 assert!(requests[3].transcript.iter().any(|item| {
7297 matches!(
7298 item,
7299 TranscriptItem::ToolResult(result)
7300 if result.name.as_deref() == Some(TASK_LEDGER_TOOL_NAME)
7301 && result.result.contains("Task ledger: 2/2 completed")
7302 )
7303 }));
7304 }
7305
7306 #[tokio::test]
7307 async fn eval_task_ledger_checkpoint_requests_scoreable_file_before_final_reserve() {
7308 let requests = Arc::new(StdMutex::new(Vec::new()));
7309 let mut builder = ExtensionRegistryBuilder::new();
7310 builder.inference_engine(Arc::new(TaskLedgerCompletionGateEngine {
7311 calls: StdMutex::new(0),
7312 requests: requests.clone(),
7313 }));
7314 builder.tool_contributor(Arc::new(
7315 roder_ext_task_ledger::TaskLedgerToolContributor::default(),
7316 ));
7317 let runtime = Arc::new(
7318 Runtime::new(
7319 builder.build().unwrap(),
7320 RuntimeConfig {
7321 runtime_profile: RuntimeProfile::Eval,
7322 policy_mode: PolicyMode::Bypass,
7323 agent_swarm_mode: false,
7324 turn_deadline_seconds: Some(120),
7325 ..RuntimeConfig::default()
7326 },
7327 )
7328 .unwrap(),
7329 );
7330 let mut rx = runtime.subscribe_events();
7331 let turn_id = runtime
7332 .start_turn(StartTurnRequest {
7333 thread_id: "thread-ledger-checkpoint".to_string(),
7334 message: "write the answer file".to_string(),
7335 images: Vec::new(),
7336 provider_override: None,
7337 model_override: None,
7338 reasoning_override: None,
7339 workspace: test_workspace(),
7340 instructions: InstructionBundle::default(),
7341 developer_context: None,
7342 task_ledger_required: true,
7343 })
7344 .await
7345 .unwrap();
7346
7347 tokio::time::timeout(std::time::Duration::from_secs(5), async {
7348 loop {
7349 let envelope = rx.recv().await.unwrap();
7350 if envelope.turn_id.as_deref() != Some(&turn_id) {
7351 continue;
7352 }
7353 match envelope.event {
7354 RoderEvent::TurnCompleted(_) => break,
7355 RoderEvent::TurnFailed(event) => panic!("turn failed: {}", event.error),
7356 _ => {}
7357 }
7358 }
7359 })
7360 .await
7361 .unwrap();
7362
7363 let requests = requests.lock().unwrap().clone();
7364 assert!(requests.len() >= 2);
7365 assert!(requests[1].transcript.iter().any(|item| {
7366 matches!(
7367 item,
7368 TranscriptItem::UserMessage(message)
7369 if message.text.contains("Scoreable Output Checkpoint")
7370 && message.text.contains("ensure the required output file(s) exist")
7371 && message.text.contains("Write /app/result.txt")
7372 )
7373 }));
7374 }
7375
7376 #[test]
7377 fn deadline_task_ledger_prompt_preserves_scoreable_work_instruction() {
7378 let prompt = task_ledger_deadline_completion_prompt(
7379 12,
7380 30,
7381 "Task Ledger Completion Required: write /app/result.txt, then call task_ledger.update",
7382 );
7383
7384 assert!(prompt.contains("12 seconds remain"));
7385 assert!(prompt.contains("create or update the required scoreable output files"));
7386 assert!(prompt.contains("write /app/result.txt"));
7387 assert!(prompt.contains(TASK_LEDGER_TOOL_NAME));
7388 }
7389
7390 #[test]
7391 fn scoreable_checkpoint_prompt_preserves_provisional_file_instruction() {
7392 let prompt = task_ledger_scoreable_checkpoint_prompt(
7393 120,
7394 "Task Ledger Completion Required: write /app/result.txt, then call task_ledger.update",
7395 );
7396
7397 assert!(prompt.contains("120 seconds remain"));
7398 assert!(prompt.contains("best evidence-backed answer"));
7399 assert!(prompt.contains("even if provisional"));
7400 assert!(prompt.contains("preserve that candidate"));
7401 assert!(prompt.contains("partial-coverage"));
7402 assert!(prompt.contains("write /app/result.txt"));
7403 assert!(prompt.contains(TASK_LEDGER_TOOL_NAME));
7404 }
7405
7406 #[test]
7407 fn open_task_ledger_moves_inference_timeout_to_scoreable_checkpoint() {
7408 let deadline = Some(OffsetDateTime::now_utc() + Duration::seconds(870));
7409 let transcript = vec![TranscriptItem::ToolResult(ToolResultRecord {
7410 id: "ledger-open".to_string(),
7411 name: Some(TASK_LEDGER_TOOL_NAME.to_string()),
7412 result: "Task ledger: 0/1 completed\n- pending: Write /app/result.txt [write]"
7413 .to_string(),
7414 display_payload: None,
7415 is_error: false,
7416 })];
7417
7418 let (_, action) = inference_timeout_deadline(
7419 deadline,
7420 RuntimeProfile::Eval,
7421 true,
7422 30,
7423 false,
7424 0,
7425 &transcript,
7426 )
7427 .unwrap();
7428
7429 assert_eq!(action, InferenceTimeoutAction::ScoreableCheckpoint);
7430 }
7431
7432 #[tokio::test]
7433 async fn verification_gate_forces_eval_code_changes_through_review() {
7434 let mut builder = ExtensionRegistryBuilder::new();
7435 builder.inference_engine(Arc::new(VerificationGateEngine {
7436 calls: StdMutex::new(0),
7437 }));
7438 builder.tool_contributor(Arc::new(WriteFileContributor));
7439 builder.tool_contributor(Arc::new(
7440 roder_ext_verification::VerificationToolContributor,
7441 ));
7442 let runtime = Arc::new(
7443 Runtime::new(
7444 builder.build().unwrap(),
7445 RuntimeConfig {
7446 default_provider: roder_api::catalog::PROVIDER_MOCK.to_string(),
7447 default_model: "mock".to_string(),
7448 runtime_profile: RuntimeProfile::Eval,
7449 policy_mode: PolicyMode::Bypass,
7450 agent_swarm_mode: false,
7451 ..RuntimeConfig::default()
7452 },
7453 )
7454 .unwrap(),
7455 );
7456 let mut rx = runtime.subscribe_events();
7457 let turn_id = runtime
7458 .start_turn(StartTurnRequest {
7459 thread_id: "thread-verification".to_string(),
7460 message: "write code".to_string(),
7461 images: Vec::new(),
7462 provider_override: None,
7463 model_override: None,
7464 reasoning_override: None,
7465 workspace: test_workspace(),
7466 instructions: InstructionBundle::default(),
7467 developer_context: None,
7468 task_ledger_required: false,
7469 })
7470 .await
7471 .unwrap();
7472
7473 let mut saw_required = false;
7474 let mut saw_completed = false;
7475 let mut final_text = String::new();
7476 tokio::time::timeout(std::time::Duration::from_secs(5), async {
7477 loop {
7478 let envelope = rx.recv().await.unwrap();
7479 if envelope.turn_id.as_deref() != Some(&turn_id) {
7480 continue;
7481 }
7482 match envelope.event {
7483 RoderEvent::VerificationRequired(event) => {
7484 saw_required = true;
7485 assert_eq!(event.changed_files, vec!["src/lib.rs"]);
7486 }
7487 RoderEvent::VerificationCompleted(event) => {
7488 saw_completed = true;
7489 assert!(event.passed);
7490 }
7491 RoderEvent::InferenceEventReceived(event) => {
7492 if let InferenceEvent::MessageDelta(delta) = event.event {
7493 final_text.push_str(&delta.text);
7494 }
7495 }
7496 RoderEvent::TurnCompleted(_) => break,
7497 _ => {}
7498 }
7499 }
7500 })
7501 .await
7502 .unwrap();
7503
7504 assert!(saw_required);
7505 assert!(saw_completed);
7506 assert!(final_text.contains("verified final"));
7507 }
7508
7509 #[tokio::test]
7510 async fn speed_policy_changes_reasoning_across_eval_model_calls_without_model_switch() {
7511 let requests = Arc::new(StdMutex::new(Vec::new()));
7512 let mut builder = ExtensionRegistryBuilder::new();
7513 builder.inference_engine(Arc::new(SpeedPolicyEngine {
7514 calls: StdMutex::new(0),
7515 requests: requests.clone(),
7516 }));
7517 builder.tool_contributor(Arc::new(WriteFileContributor));
7518 builder.tool_contributor(Arc::new(
7519 roder_ext_verification::VerificationToolContributor,
7520 ));
7521 let runtime = Arc::new(
7522 Runtime::new(
7523 builder.build().unwrap(),
7524 RuntimeConfig {
7525 default_provider: roder_api::catalog::PROVIDER_MOCK.to_string(),
7526 default_model: "gpt-5.5".to_string(),
7527 runtime_profile: RuntimeProfile::Eval,
7528 policy_mode: PolicyMode::Bypass,
7529 agent_swarm_mode: false,
7530 ..RuntimeConfig::default()
7531 },
7532 )
7533 .unwrap(),
7534 );
7535 let mut rx = runtime.subscribe_events();
7536 let turn_id = runtime
7537 .start_turn(StartTurnRequest {
7538 thread_id: "thread-speed-policy".to_string(),
7539 message: "write code".to_string(),
7540 images: Vec::new(),
7541 provider_override: None,
7542 model_override: None,
7543 reasoning_override: None,
7544 workspace: test_workspace(),
7545 instructions: InstructionBundle::default(),
7546 developer_context: None,
7547 task_ledger_required: false,
7548 })
7549 .await
7550 .unwrap();
7551
7552 let mut saw_speed_policy_event = false;
7553 tokio::time::timeout(std::time::Duration::from_secs(5), async {
7554 loop {
7555 let envelope = rx.recv().await.unwrap();
7556 if envelope.turn_id.as_deref() != Some(&turn_id) {
7557 continue;
7558 }
7559 match envelope.event {
7560 RoderEvent::InferenceStarted(event) => {
7561 if event.speed_policy.is_some() {
7562 saw_speed_policy_event = true;
7563 }
7564 }
7565 RoderEvent::TurnCompleted(_) => break,
7566 RoderEvent::TurnFailed(event) => panic!("turn failed: {}", event.error),
7567 _ => {}
7568 }
7569 }
7570 })
7571 .await
7572 .unwrap();
7573
7574 let requests = requests.lock().unwrap().clone();
7575 assert!(saw_speed_policy_event);
7576 assert!(requests.len() >= 4);
7577 assert!(requests.iter().all(|request| {
7578 request.model.provider == roder_api::catalog::PROVIDER_MOCK
7579 && request.model.model == "gpt-5.5"
7580 }));
7581 assert_eq!(
7582 requests[0].runtime.speed_policy.as_ref().map(|d| d.phase),
7583 Some(roder_api::inference::SpeedPolicyPhase::Orientation)
7584 );
7585 assert_eq!(requests[0].reasoning.level.as_deref(), Some(REASONING_HIGH));
7586 assert_eq!(
7587 requests[1].runtime.speed_policy.as_ref().map(|d| d.phase),
7588 Some(roder_api::inference::SpeedPolicyPhase::Execution)
7589 );
7590 assert_eq!(requests[1].reasoning.level.as_deref(), Some(REASONING_LOW));
7591 assert_eq!(
7592 requests[2].runtime.speed_policy.as_ref().map(|d| d.phase),
7593 Some(roder_api::inference::SpeedPolicyPhase::Verification)
7594 );
7595 assert_eq!(requests[2].reasoning.level.as_deref(), Some(REASONING_HIGH));
7596 assert_eq!(
7597 requests[2]
7598 .metadata
7599 .pointer("/speedPolicy/phase")
7600 .and_then(serde_json::Value::as_str),
7601 Some("verification")
7602 );
7603 }
7604
7605 #[tokio::test]
7606 async fn deadline_turn_timeout_emits_partial_result_and_clears_active_turn() {
7607 let mut builder = ExtensionRegistryBuilder::new();
7608 builder.inference_engine(Arc::new(DeadlineEngine));
7609 let runtime = Arc::new(
7610 Runtime::new(
7611 builder.build().unwrap(),
7612 RuntimeConfig {
7613 default_provider: roder_api::catalog::PROVIDER_MOCK.to_string(),
7614 default_model: "mock".to_string(),
7615 runtime_profile: RuntimeProfile::Eval,
7616 turn_deadline_seconds: Some(1),
7617 ..RuntimeConfig::default()
7618 },
7619 )
7620 .unwrap(),
7621 );
7622 let mut rx = runtime.subscribe_events();
7623 let turn_id = runtime
7624 .start_turn(StartTurnRequest {
7625 thread_id: "thread-deadline".to_string(),
7626 message: "slow work".to_string(),
7627 images: Vec::new(),
7628 provider_override: None,
7629 model_override: None,
7630 reasoning_override: None,
7631 workspace: test_workspace(),
7632 instructions: InstructionBundle::default(),
7633 developer_context: None,
7634 task_ledger_required: false,
7635 })
7636 .await
7637 .unwrap();
7638
7639 let mut saw_partial = false;
7640 let mut saw_deadline = false;
7641 let mut failed_kind = None;
7642 tokio::time::timeout(std::time::Duration::from_secs(5), async {
7643 loop {
7644 let envelope = rx.recv().await.unwrap();
7645 if envelope.turn_id.as_deref() != Some(&turn_id) {
7646 continue;
7647 }
7648 match envelope.event {
7649 RoderEvent::TurnPartialResult(event) => {
7650 saw_partial = event.summary.contains("partial turn state");
7651 }
7652 RoderEvent::TurnDeadlineExceeded(event) => {
7653 saw_deadline = event.partial_result.contains("transcript items");
7654 }
7655 RoderEvent::TurnFailed(event) => {
7656 failed_kind = event.error_kind;
7657 break;
7658 }
7659 _ => {}
7660 }
7661 }
7662 })
7663 .await
7664 .unwrap();
7665
7666 for _ in 0..20 {
7667 if !runtime.active_turns.read().await.contains_key(&turn_id) {
7668 break;
7669 }
7670 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
7671 }
7672 assert!(saw_partial);
7673 assert!(saw_deadline);
7674 assert_eq!(failed_kind.as_deref(), Some("deadline_timeout"));
7675 assert!(!runtime.active_turns.read().await.contains_key(&turn_id));
7676 }
7677
7678 #[tokio::test]
7679 async fn deadline_skips_subagent_task_when_remaining_budget_is_too_low() {
7680 let calls = Arc::new(StdMutex::new(0));
7681 let mut builder = ExtensionRegistryBuilder::new();
7682 builder.inference_engine(Arc::new(CapturingEngine {
7683 request: Arc::new(StdMutex::new(None)),
7684 }));
7685 let task_tool = Arc::new(CountingTaskTool {
7686 calls: calls.clone(),
7687 });
7688 builder.tool_contributor(Arc::new(TestToolContributor { tool: task_tool }));
7689 let runtime = Arc::new(
7690 Runtime::new(
7691 builder.build().unwrap(),
7692 RuntimeConfig {
7693 policy_mode: PolicyMode::Bypass,
7694 agent_swarm_mode: false,
7695 ..RuntimeConfig::default()
7696 },
7697 )
7698 .unwrap(),
7699 );
7700
7701 let result = runtime
7702 .route_tool_call(
7703 &"thread-deadline-task".to_string(),
7704 &"turn-deadline-task".to_string(),
7705 ToolCallCompleted {
7706 id: "task-1".to_string(),
7707 name: "task".to_string(),
7708 arguments: serde_json::json!({
7709 "description": "inspect",
7710 "prompt": "read"
7711 })
7712 .to_string(),
7713 },
7714 None,
7715 Some(OffsetDateTime::now_utc() + Duration::seconds(1)),
7716 )
7717 .await
7718 .unwrap();
7719
7720 assert!(result.is_error);
7721 assert!(result.result.contains("deadline policy skipped"));
7722 assert_eq!(*calls.lock().unwrap(), 0);
7723 }
7724
7725 struct TestToolContributor {
7726 tool: Arc<dyn ToolExecutor>,
7727 }
7728
7729 impl ToolContributor for TestToolContributor {
7730 fn id(&self) -> String {
7731 "test-tool".to_string()
7732 }
7733
7734 fn contribute(&self, registry: &mut ToolRegistry) -> anyhow::Result<()> {
7735 registry.register(self.tool.clone())
7736 }
7737 }
7738
7739 #[tokio::test]
7740 async fn agent_swarm_mode_override_is_per_thread() {
7741 let runtime = Runtime::fake().unwrap();
7742 let trigger = roder_api::subagents::AgentSwarmModeTrigger::Manual;
7743
7744 assert!(
7746 !runtime
7747 .effective_agent_swarm_mode_for_thread("thread-a")
7748 .await
7749 );
7750 assert!(
7751 !runtime
7752 .effective_agent_swarm_mode_for_thread("thread-b")
7753 .await
7754 );
7755
7756 assert!(
7758 runtime
7759 .set_agent_swarm_mode_for_thread("thread-a", true, trigger)
7760 .await
7761 );
7762 assert!(
7763 runtime
7764 .effective_agent_swarm_mode_for_thread("thread-a")
7765 .await
7766 );
7767 assert!(
7768 !runtime
7769 .effective_agent_swarm_mode_for_thread("thread-b")
7770 .await
7771 );
7772
7773 runtime
7775 .set_agent_swarm_mode(true, trigger)
7776 .await
7777 .unwrap();
7778 runtime
7779 .set_agent_swarm_mode_for_thread("thread-b", false, trigger)
7780 .await;
7781 assert!(
7782 !runtime
7783 .effective_agent_swarm_mode_for_thread("thread-b")
7784 .await,
7785 "explicit per-thread off overrides the global on default"
7786 );
7787 assert!(
7789 runtime
7790 .effective_agent_swarm_mode_for_thread("thread-c")
7791 .await,
7792 "threads without an override follow the runtime-global default"
7793 );
7794 }
7795
7796 #[tokio::test]
7797 async fn set_agent_swarm_mode_for_thread_emits_event_with_real_thread_id() {
7798 let runtime = Runtime::fake().unwrap();
7799 let mut events = runtime.subscribe_events();
7800 runtime
7801 .set_agent_swarm_mode_for_thread(
7802 "thread-xyz",
7803 true,
7804 roder_api::subagents::AgentSwarmModeTrigger::Task,
7805 )
7806 .await;
7807 let mut saw = false;
7808 for _ in 0..8 {
7809 let envelope = tokio::time::timeout(std::time::Duration::from_secs(2), events.recv())
7810 .await
7811 .unwrap()
7812 .unwrap();
7813 if let RoderEvent::AgentSwarmModeChanged(event) = envelope.event {
7814 assert_eq!(event.thread_id, "thread-xyz");
7815 assert!(event.enabled);
7816 assert_eq!(event.trigger, roder_api::subagents::AgentSwarmModeTrigger::Task);
7817 saw = true;
7818 break;
7819 }
7820 }
7821 assert!(saw, "expected an AgentSwarmModeChanged event for the thread");
7822 }
7823}