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