steerable-agent-runtime-native 0.6.31__tar.gz → 0.6.32__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. {steerable_agent_runtime_native-0.6.31 → steerable_agent_runtime_native-0.6.32}/Cargo.lock +1 -1
  2. {steerable_agent_runtime_native-0.6.31 → steerable_agent_runtime_native-0.6.32}/Cargo.toml +1 -1
  3. {steerable_agent_runtime_native-0.6.31 → steerable_agent_runtime_native-0.6.32}/PKG-INFO +1 -1
  4. {steerable_agent_runtime_native-0.6.31 → steerable_agent_runtime_native-0.6.32}/pyproject.toml +1 -1
  5. {steerable_agent_runtime_native-0.6.31 → steerable_agent_runtime_native-0.6.32}/src/engine.rs +324 -266
  6. {steerable_agent_runtime_native-0.6.31 → steerable_agent_runtime_native-0.6.32}/tests/test_loop.rs +121 -2
  7. {steerable_agent_runtime_native-0.6.31 → steerable_agent_runtime_native-0.6.32}/.gitignore +0 -0
  8. {steerable_agent_runtime_native-0.6.31 → steerable_agent_runtime_native-0.6.32}/README.md +0 -0
  9. {steerable_agent_runtime_native-0.6.31 → steerable_agent_runtime_native-0.6.32}/src/anthropic_http.rs +0 -0
  10. {steerable_agent_runtime_native-0.6.31 → steerable_agent_runtime_native-0.6.32}/src/anthropic_wire.rs +0 -0
  11. {steerable_agent_runtime_native-0.6.31 → steerable_agent_runtime_native-0.6.32}/src/budget.rs +0 -0
  12. {steerable_agent_runtime_native-0.6.31 → steerable_agent_runtime_native-0.6.32}/src/compat.rs +0 -0
  13. {steerable_agent_runtime_native-0.6.31 → steerable_agent_runtime_native-0.6.32}/src/errors.rs +0 -0
  14. {steerable_agent_runtime_native-0.6.31 → steerable_agent_runtime_native-0.6.32}/src/executor.rs +0 -0
  15. {steerable_agent_runtime_native-0.6.31 → steerable_agent_runtime_native-0.6.32}/src/gemini_http.rs +0 -0
  16. {steerable_agent_runtime_native-0.6.31 → steerable_agent_runtime_native-0.6.32}/src/gemini_wire.rs +0 -0
  17. {steerable_agent_runtime_native-0.6.31 → steerable_agent_runtime_native-0.6.32}/src/hooks.rs +0 -0
  18. {steerable_agent_runtime_native-0.6.31 → steerable_agent_runtime_native-0.6.32}/src/json_py.rs +0 -0
  19. {steerable_agent_runtime_native-0.6.31 → steerable_agent_runtime_native-0.6.32}/src/lib.rs +0 -0
  20. {steerable_agent_runtime_native-0.6.31 → steerable_agent_runtime_native-0.6.32}/src/model_catalog.json +0 -0
  21. {steerable_agent_runtime_native-0.6.31 → steerable_agent_runtime_native-0.6.32}/src/model_resolve.rs +0 -0
  22. {steerable_agent_runtime_native-0.6.31 → steerable_agent_runtime_native-0.6.32}/src/openai_http.rs +0 -0
  23. {steerable_agent_runtime_native-0.6.31 → steerable_agent_runtime_native-0.6.32}/src/openai_wire.rs +0 -0
  24. {steerable_agent_runtime_native-0.6.31 → steerable_agent_runtime_native-0.6.32}/src/presets.rs +0 -0
  25. {steerable_agent_runtime_native-0.6.31 → steerable_agent_runtime_native-0.6.32}/src/pricing.rs +0 -0
  26. {steerable_agent_runtime_native-0.6.31 → steerable_agent_runtime_native-0.6.32}/src/provider.rs +0 -0
  27. {steerable_agent_runtime_native-0.6.31 → steerable_agent_runtime_native-0.6.32}/src/ptc_js.rs +0 -0
  28. {steerable_agent_runtime_native-0.6.31 → steerable_agent_runtime_native-0.6.32}/src/python.rs +0 -0
  29. {steerable_agent_runtime_native-0.6.31 → steerable_agent_runtime_native-0.6.32}/src/responses_http.rs +0 -0
  30. {steerable_agent_runtime_native-0.6.31 → steerable_agent_runtime_native-0.6.32}/src/responses_wire.rs +0 -0
  31. {steerable_agent_runtime_native-0.6.31 → steerable_agent_runtime_native-0.6.32}/src/run_code.rs +0 -0
  32. {steerable_agent_runtime_native-0.6.31 → steerable_agent_runtime_native-0.6.32}/src/skills.rs +0 -0
  33. {steerable_agent_runtime_native-0.6.31 → steerable_agent_runtime_native-0.6.32}/src/sse_http.rs +0 -0
  34. {steerable_agent_runtime_native-0.6.31 → steerable_agent_runtime_native-0.6.32}/src/todo.rs +0 -0
  35. {steerable_agent_runtime_native-0.6.31 → steerable_agent_runtime_native-0.6.32}/src/tokens.rs +0 -0
  36. {steerable_agent_runtime_native-0.6.31 → steerable_agent_runtime_native-0.6.32}/src/types.rs +0 -0
  37. {steerable_agent_runtime_native-0.6.31 → steerable_agent_runtime_native-0.6.32}/src/web.rs +0 -0
  38. {steerable_agent_runtime_native-0.6.31 → steerable_agent_runtime_native-0.6.32}/src/web_html.rs +0 -0
  39. {steerable_agent_runtime_native-0.6.31 → steerable_agent_runtime_native-0.6.32}/src/web_search.rs +0 -0
  40. {steerable_agent_runtime_native-0.6.31 → steerable_agent_runtime_native-0.6.32}/src/wire.rs +0 -0
  41. {steerable_agent_runtime_native-0.6.31 → steerable_agent_runtime_native-0.6.32}/src/write_lease.rs +0 -0
  42. {steerable_agent_runtime_native-0.6.31 → steerable_agent_runtime_native-0.6.32}/tests/test_llm_wire.rs +0 -0
  43. {steerable_agent_runtime_native-0.6.31 → steerable_agent_runtime_native-0.6.32}/tests/test_ptc_js.rs +0 -0
  44. {steerable_agent_runtime_native-0.6.31 → steerable_agent_runtime_native-0.6.32}/tests/test_run_code.rs +0 -0
  45. {steerable_agent_runtime_native-0.6.31 → steerable_agent_runtime_native-0.6.32}/tests/test_todo.rs +0 -0
  46. {steerable_agent_runtime_native-0.6.31 → steerable_agent_runtime_native-0.6.32}/tests/test_web.rs +0 -0
  47. {steerable_agent_runtime_native-0.6.31 → steerable_agent_runtime_native-0.6.32}/tests/test_write_lease.rs +0 -0
  48. {steerable_agent_runtime_native-0.6.31 → steerable_agent_runtime_native-0.6.32}/uv.lock +0 -0
@@ -955,7 +955,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
955
955
 
956
956
  [[package]]
957
957
  name = "steerable-agent-runtime"
958
- version = "0.6.31"
958
+ version = "0.6.32"
959
959
  dependencies = [
960
960
  "async-stream",
961
961
  "async-trait",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "steerable-agent-runtime"
3
- version = "0.6.31"
3
+ version = "0.6.32"
4
4
  edition = "2021"
5
5
  description = "Steerable CoreLoop in Rust: think → act → observe, yielding LoopEvents."
6
6
  license = "MIT"
@@ -1,5 +1,5 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: steerable-agent-runtime-native
3
- Version: 0.6.31
3
+ Version: 0.6.32
4
4
  Summary: PyO3 bindings for the Rust CoreLoop.
5
5
  Requires-Python: >=3.10
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "steerable-agent-runtime-native"
7
- version = "0.6.31"
7
+ version = "0.6.32"
8
8
  description = "PyO3 bindings for the Rust CoreLoop."
9
9
  requires-python = ">=3.10"
10
10
 
@@ -467,19 +467,41 @@ impl<P: LLMProvider, E: ToolExecutor, H: LoopHooks> CoreLoop<P, E, H> {
467
467
  self.tools.clone()
468
468
  };
469
469
  let mut llm_started;
470
- let chunks = loop {
470
+ let mut content = String::new();
471
+ let mut reasoning = String::new();
472
+ let mut reasoning_details = Vec::new();
473
+ let mut tool_calls: Vec<ToolCall> = Vec::new();
474
+ let mut token_exhausted;
475
+ let mut stream_cancelled;
476
+ let mut stream_soft_cut;
477
+ let mut stream_wrap_cut;
478
+ let mut stream_idle_cut;
479
+ let mut stream_failed = false;
480
+ // Mid-stream errors share on_request_error with stream_with_options
481
+ // failures. Retry drops this attempt's partial text and re-streams;
482
+ // Fail keeps it and ends the run.
483
+ 'stream_attempt: loop {
484
+ content.clear();
485
+ reasoning.clear();
486
+ reasoning_details.clear();
487
+ tool_calls.clear();
488
+ token_exhausted = false;
489
+ stream_cancelled = false;
490
+ stream_soft_cut = false;
491
+ stream_wrap_cut = false;
492
+ stream_idle_cut = None;
471
493
  llm_started = std::time::Instant::now();
472
494
  emit(LoopEvent::new(
473
495
  "llm_request",
474
496
  json!({ "round": round_index, "attempt": llm_attempt }),
475
497
  ));
476
498
  self.observe_history(&history);
477
- match self
499
+ let mut chunks = match self
478
500
  .provider
479
501
  .stream_with_options(&history, &request_tools, step_tool_choice.as_deref())
480
502
  .await
481
503
  {
482
- Ok(chunks) => break Some(chunks),
504
+ Ok(chunks) => chunks,
483
505
  Err(error) => {
484
506
  let message = error.message.clone();
485
507
  let kind = error.kind.as_str();
@@ -508,12 +530,17 @@ impl<P: LLMProvider, E: ToolExecutor, H: LoopHooks> CoreLoop<P, E, H> {
508
530
  history = rewrite;
509
531
  self.observe_history(&history);
510
532
  }
533
+ let retry_reason = if reason.is_empty() {
534
+ message.clone()
535
+ } else {
536
+ reason
537
+ };
511
538
  emit(LoopEvent::new(
512
539
  "hook_action",
513
540
  json!({
514
541
  "hook": "on_request_error",
515
542
  "action": "retry",
516
- "reason": reason,
543
+ "reason": retry_reason,
517
544
  "delayMs": delay_ms,
518
545
  "compacted": compacted,
519
546
  "round": round_index,
@@ -524,6 +551,7 @@ impl<P: LLMProvider, E: ToolExecutor, H: LoopHooks> CoreLoop<P, E, H> {
524
551
  .await;
525
552
  }
526
553
  llm_attempt += 1;
554
+ continue 'stream_attempt;
527
555
  }
528
556
  RequestErrorAction::Fail { reason } => {
529
557
  let failure_reason = if reason.is_empty() {
@@ -555,249 +583,323 @@ impl<P: LLMProvider, E: ToolExecutor, H: LoopHooks> CoreLoop<P, E, H> {
555
583
  0.9,
556
584
  &ctx,
557
585
  ));
558
- break None;
586
+ stream_failed = true;
587
+ break 'stream_attempt;
559
588
  }
560
589
  }
561
590
  }
562
- }
563
- };
564
- let Some(mut chunks) = chunks else {
565
- break;
566
- };
567
- let mut content = String::new();
568
- let mut reasoning = String::new();
569
- let mut reasoning_details = Vec::new();
570
- let mut tool_calls: Vec<ToolCall> = Vec::new();
571
- let mut token_exhausted = false;
572
- let mut stream_cancelled = false;
573
- let mut stream_provider_error = None;
574
- let mut stream_soft_cut = false;
575
- let mut stream_wrap_cut = false;
576
- let mut stream_idle_cut: Option<(String, u64, u64)> = None;
577
- let mut stream_active_ms = 0_u64;
578
- let mut stream_chars = 0_u64;
579
- let mut stream_has_tool = false;
580
- let mut stream_progress_accounted = false;
581
- let mut last_chunk_at = llm_started;
582
- let stream_budget = if wrap_up {
583
- self.config
584
- .wrap_up_tool_timeout_ms
585
- .map(|limit| (limit.max(50), true))
586
- } else {
587
- self.config.soft_timeout_ms.map(|limit| {
588
- let elapsed =
589
- u64::try_from(run_started.elapsed().as_millis()).unwrap_or(u64::MAX);
590
- (limit.saturating_sub(elapsed).max(50), false)
591
- })
592
- };
591
+ };
592
+ let mut stream_provider_error = None;
593
+ let mut stream_active_ms = 0_u64;
594
+ let mut stream_chars = 0_u64;
595
+ let mut stream_has_tool = false;
596
+ let mut stream_progress_accounted = false;
597
+ let mut last_chunk_at = llm_started;
598
+ let stream_budget = if wrap_up {
599
+ self.config
600
+ .wrap_up_tool_timeout_ms
601
+ .map(|limit| (limit.max(50), true))
602
+ } else {
603
+ self.config.soft_timeout_ms.map(|limit| {
604
+ let elapsed =
605
+ u64::try_from(run_started.elapsed().as_millis()).unwrap_or(u64::MAX);
606
+ (limit.saturating_sub(elapsed).max(50), false)
607
+ })
608
+ };
593
609
 
594
- loop {
595
- let deadline = stream_budget.map(|(limit, is_wrap)| {
596
- (
597
- limit.saturating_sub(
598
- u64::try_from(llm_started.elapsed().as_millis()).unwrap_or(u64::MAX),
599
- ),
600
- is_wrap,
601
- )
602
- });
603
- if let Some((0, is_wrap)) = deadline {
604
- if is_wrap {
605
- stream_wrap_cut = true;
606
- } else {
607
- stream_soft_cut = true;
610
+ loop {
611
+ let deadline = stream_budget.map(|(limit, is_wrap)| {
612
+ (
613
+ limit.saturating_sub(
614
+ u64::try_from(llm_started.elapsed().as_millis())
615
+ .unwrap_or(u64::MAX),
616
+ ),
617
+ is_wrap,
618
+ )
619
+ });
620
+ if let Some((0, is_wrap)) = deadline {
621
+ if is_wrap {
622
+ stream_wrap_cut = true;
623
+ } else {
624
+ stream_soft_cut = true;
625
+ }
626
+ break;
608
627
  }
609
- break;
610
- }
611
- let wait_ms = deadline
612
- .map(|(remaining, _)| remaining.min(50))
613
- .unwrap_or(50);
614
- let chunk = loop {
615
- tokio::select! {
616
- chunk = chunks.next() => break chunk,
617
- _ = tokio::time::sleep(std::time::Duration::from_millis(wait_ms)) => {
618
- if self.poll(false).cancel {
619
- stream_cancelled = true;
620
- break None;
621
- }
622
- if let Some((remaining, is_wrap)) = deadline {
623
- if remaining <= wait_ms {
624
- if is_wrap {
625
- stream_wrap_cut = true;
626
- } else {
627
- stream_soft_cut = true;
628
- }
628
+ let wait_ms = deadline
629
+ .map(|(remaining, _)| remaining.min(50))
630
+ .unwrap_or(50);
631
+ let chunk = loop {
632
+ tokio::select! {
633
+ chunk = chunks.next() => break chunk,
634
+ _ = tokio::time::sleep(std::time::Duration::from_millis(wait_ms)) => {
635
+ if self.poll(false).cancel {
636
+ stream_cancelled = true;
629
637
  break None;
630
638
  }
639
+ if let Some((remaining, is_wrap)) = deadline {
640
+ if remaining <= wait_ms {
641
+ if is_wrap {
642
+ stream_wrap_cut = true;
643
+ } else {
644
+ stream_soft_cut = true;
645
+ }
646
+ break None;
647
+ }
648
+ }
631
649
  }
632
650
  }
633
- }
634
- };
635
- let Some(chunk) = chunk else {
636
- break;
637
- };
638
- let chunk = match chunk {
639
- Ok(chunk) => chunk,
640
- Err(error) => {
641
- stream_provider_error = Some(error);
651
+ };
652
+ let Some(chunk) = chunk else {
642
653
  break;
654
+ };
655
+ let chunk = match chunk {
656
+ Ok(chunk) => chunk,
657
+ Err(error) => {
658
+ stream_provider_error = Some(error);
659
+ break;
660
+ }
661
+ };
662
+ let now = std::time::Instant::now();
663
+ let gap_ms = u64::try_from(now.duration_since(last_chunk_at).as_millis())
664
+ .unwrap_or(u64::MAX);
665
+ last_chunk_at = now;
666
+ if gap_ms <= self.config.idle_reasoning_gap_ms {
667
+ stream_active_ms = stream_active_ms.saturating_add(gap_ms);
643
668
  }
644
- };
645
- let now = std::time::Instant::now();
646
- let gap_ms = u64::try_from(now.duration_since(last_chunk_at).as_millis())
647
- .unwrap_or(u64::MAX);
648
- last_chunk_at = now;
649
- if gap_ms <= self.config.idle_reasoning_gap_ms {
650
- stream_active_ms = stream_active_ms.saturating_add(gap_ms);
651
- }
652
- let chunk_content_chars = chunk
653
- .content_delta
654
- .as_deref()
655
- .map(|text| text.chars().count() as u64)
656
- .unwrap_or(0);
657
- let chunk_reasoning_chars = chunk
658
- .reasoning_delta
659
- .as_deref()
660
- .map(|text| text.chars().count() as u64)
661
- .unwrap_or(0);
662
- let chunk_has_tool = chunk.tool_call_delta.is_some();
663
- if let Some(finish_reason) = chunk.finish_reason.as_deref() {
664
- if finish_reason == "__soft_timeout_cut__" {
665
- stream_soft_cut = true;
666
- break;
669
+ let chunk_content_chars = chunk
670
+ .content_delta
671
+ .as_deref()
672
+ .map(|text| text.chars().count() as u64)
673
+ .unwrap_or(0);
674
+ let chunk_reasoning_chars = chunk
675
+ .reasoning_delta
676
+ .as_deref()
677
+ .map(|text| text.chars().count() as u64)
678
+ .unwrap_or(0);
679
+ let chunk_has_tool = chunk.tool_call_delta.is_some();
680
+ if let Some(finish_reason) = chunk.finish_reason.as_deref() {
681
+ if finish_reason == "__soft_timeout_cut__" {
682
+ stream_soft_cut = true;
683
+ break;
684
+ }
685
+ if finish_reason == "__wrap_stream_cut__" {
686
+ stream_wrap_cut = true;
687
+ break;
688
+ }
689
+ if let Some(details) = finish_reason.strip_prefix("__idle_stream_cut__:") {
690
+ let mut parts = details.split(':');
691
+ let trigger = parts.next().unwrap_or("active_ms").to_string();
692
+ let chars = parts
693
+ .next()
694
+ .and_then(|value| value.parse().ok())
695
+ .unwrap_or(0);
696
+ let stale_chars = parts
697
+ .next()
698
+ .and_then(|value| value.parse().ok())
699
+ .unwrap_or(0);
700
+ stream_idle_cut = Some((trigger, chars, stale_chars));
701
+ break;
702
+ }
667
703
  }
668
- if finish_reason == "__wrap_stream_cut__" {
669
- stream_wrap_cut = true;
670
- break;
704
+ if let Some(delta) = chunk.content_delta {
705
+ content.push_str(&delta);
706
+ emit(LoopEvent::new("content_delta", json!({ "delta": delta })));
671
707
  }
672
- if let Some(details) = finish_reason.strip_prefix("__idle_stream_cut__:") {
673
- let mut parts = details.split(':');
674
- let trigger = parts.next().unwrap_or("active_ms").to_string();
675
- let chars = parts
676
- .next()
677
- .and_then(|value| value.parse().ok())
678
- .unwrap_or(0);
679
- let stale_chars = parts
680
- .next()
681
- .and_then(|value| value.parse().ok())
682
- .unwrap_or(0);
683
- stream_idle_cut = Some((trigger, chars, stale_chars));
708
+ if let Some(delta) = chunk.reasoning_delta {
709
+ reasoning.push_str(&delta);
710
+ emit(LoopEvent::new("reasoning_delta", json!({ "delta": delta })));
711
+ }
712
+ if let Some(details) = chunk.reasoning_details {
713
+ match details {
714
+ Value::Array(items) => reasoning_details.extend(items),
715
+ item => reasoning_details.push(item),
716
+ }
717
+ }
718
+ if let Some(call) = chunk.tool_call_delta {
719
+ tool_calls.push(call);
720
+ }
721
+ if let Some(usage) = chunk.usage {
722
+ ctx.last_prompt_tokens = usage.prompt_tokens;
723
+ ctx.last_prompt_transcript_len = history.len();
724
+ ctx.last_cached_prompt_tokens = usage.cached_prompt_tokens;
725
+ ctx.last_cache_creation_tokens = usage.cache_creation_tokens;
726
+ ctx.accumulated_prompt_tokens += usage.prompt_tokens;
727
+ ctx.accumulated_completion_tokens += usage.completion_tokens;
728
+ self.last_run_usage = Some(LLMUsage {
729
+ prompt_tokens: ctx.accumulated_prompt_tokens,
730
+ completion_tokens: ctx.accumulated_completion_tokens,
731
+ total_tokens: ctx.accumulated_prompt_tokens
732
+ + ctx.accumulated_completion_tokens,
733
+ cached_prompt_tokens: usage.cached_prompt_tokens,
734
+ cache_creation_tokens: usage.cache_creation_tokens,
735
+ });
736
+ self.observe_context(&ctx, round_index);
737
+ if let Some(limits) = &self.config.budget {
738
+ let (next, exhausted) = consume_budget(
739
+ &budget_state,
740
+ limits,
741
+ usage.total_tokens,
742
+ usage.cached_prompt_tokens,
743
+ false,
744
+ false,
745
+ );
746
+ budget_state = next;
747
+ if exhausted {
748
+ emit(LoopEvent::new(
749
+ "budget_exhausted",
750
+ json!({ "budget": "tokens", "used": budget_state.tokens_used }),
751
+ ));
752
+ emit(completion_event(
753
+ round_index,
754
+ "stop",
755
+ &content,
756
+ &tool_calls,
757
+ ctx.consecutive_tool_errors,
758
+ "budget_exhausted",
759
+ "token budget exceeded",
760
+ 1.0,
761
+ &ctx,
762
+ ));
763
+ token_exhausted = true;
764
+ break;
765
+ }
766
+ }
767
+ }
768
+ if self.poll(false).cancel {
769
+ stream_cancelled = true;
684
770
  break;
685
771
  }
686
- }
687
- if let Some(delta) = chunk.content_delta {
688
- content.push_str(&delta);
689
- emit(LoopEvent::new("content_delta", json!({ "delta": delta })));
690
- }
691
- if let Some(delta) = chunk.reasoning_delta {
692
- reasoning.push_str(&delta);
693
- emit(LoopEvent::new("reasoning_delta", json!({ "delta": delta })));
694
- }
695
- if let Some(details) = chunk.reasoning_details {
696
- match details {
697
- Value::Array(items) => reasoning_details.extend(items),
698
- item => reasoning_details.push(item),
772
+ if !stream_has_tool && !chunk_has_tool {
773
+ let delta_chars = chunk_content_chars.saturating_add(chunk_reasoning_chars);
774
+ stream_chars = stream_chars.saturating_add(delta_chars);
775
+ let stale_chars = ctx.reasoning_since_progress.saturating_add(stream_chars);
776
+ let trigger = if self
777
+ .config
778
+ .idle_stream_timeout_ms
779
+ .is_some_and(|limit| stream_active_ms >= limit)
780
+ {
781
+ Some("active_ms")
782
+ } else if self
783
+ .config
784
+ .idle_stream_max_chars
785
+ .is_some_and(|limit| stream_chars >= limit)
786
+ {
787
+ Some("chars")
788
+ } else if self
789
+ .config
790
+ .reasoning_without_progress_chars
791
+ .is_some_and(|limit| stale_chars >= limit)
792
+ {
793
+ Some("no_progress")
794
+ } else {
795
+ None
796
+ };
797
+ if let Some(trigger) = trigger {
798
+ if trigger == "no_progress" {
799
+ ctx.reasoning_since_progress = 0;
800
+ } else {
801
+ ctx.reasoning_since_progress = stale_chars;
802
+ }
803
+ stream_progress_accounted = true;
804
+ stream_idle_cut =
805
+ Some((trigger.to_string(), stream_chars, stale_chars));
806
+ break;
807
+ }
808
+ }
809
+ if chunk_has_tool {
810
+ stream_has_tool = true;
699
811
  }
700
812
  }
701
- if let Some(call) = chunk.tool_call_delta {
702
- tool_calls.push(call);
703
- }
704
- if let Some(usage) = chunk.usage {
705
- ctx.last_prompt_tokens = usage.prompt_tokens;
706
- ctx.last_prompt_transcript_len = history.len();
707
- ctx.last_cached_prompt_tokens = usage.cached_prompt_tokens;
708
- ctx.last_cache_creation_tokens = usage.cache_creation_tokens;
709
- ctx.accumulated_prompt_tokens += usage.prompt_tokens;
710
- ctx.accumulated_completion_tokens += usage.completion_tokens;
711
- self.last_run_usage = Some(LLMUsage {
712
- prompt_tokens: ctx.accumulated_prompt_tokens,
713
- completion_tokens: ctx.accumulated_completion_tokens,
714
- total_tokens: ctx.accumulated_prompt_tokens
715
- + ctx.accumulated_completion_tokens,
716
- cached_prompt_tokens: usage.cached_prompt_tokens,
717
- cache_creation_tokens: usage.cache_creation_tokens,
718
- });
719
- self.observe_context(&ctx, round_index);
720
- if let Some(limits) = &self.config.budget {
721
- let (next, exhausted) = consume_budget(
722
- &budget_state,
723
- limits,
724
- usage.total_tokens,
725
- usage.cached_prompt_tokens,
726
- false,
727
- false,
728
- );
729
- budget_state = next;
730
- if exhausted {
813
+ if let Some(error) = stream_provider_error {
814
+ let message = error.message.clone();
815
+ let kind = error.kind.as_str();
816
+ emit(LoopEvent::new(
817
+ "llm_response",
818
+ json!({
819
+ "round": round_index,
820
+ "attempt": llm_attempt,
821
+ "durationMs": llm_started.elapsed().as_millis(),
822
+ "error": message,
823
+ "errorKind": kind,
824
+ }),
825
+ ));
826
+ match self
827
+ .hooks
828
+ .on_request_error(&error, &history, round_index)
829
+ .await
830
+ {
831
+ RequestErrorAction::Retry {
832
+ delay_ms,
833
+ reason,
834
+ rewrite,
835
+ } => {
836
+ let compacted = rewrite.is_some();
837
+ if let Some(rewrite) = rewrite {
838
+ history = rewrite;
839
+ self.observe_history(&history);
840
+ }
841
+ let retry_reason = if reason.is_empty() {
842
+ message.clone()
843
+ } else {
844
+ reason
845
+ };
731
846
  emit(LoopEvent::new(
732
- "budget_exhausted",
733
- json!({ "budget": "tokens", "used": budget_state.tokens_used }),
847
+ "hook_action",
848
+ json!({
849
+ "hook": "on_request_error",
850
+ "action": "retry",
851
+ "reason": retry_reason,
852
+ "delayMs": delay_ms,
853
+ "compacted": compacted,
854
+ "round": round_index,
855
+ }),
856
+ ));
857
+ if delay_ms > 0 {
858
+ tokio::time::sleep(std::time::Duration::from_millis(delay_ms))
859
+ .await;
860
+ }
861
+ llm_attempt += 1;
862
+ continue 'stream_attempt;
863
+ }
864
+ RequestErrorAction::Fail { reason } => {
865
+ let failure_reason = if reason.is_empty() {
866
+ format!("llm stream error: {message}")
867
+ } else {
868
+ reason
869
+ };
870
+ emit(LoopEvent::new(
871
+ "error",
872
+ json!({
873
+ "message": message,
874
+ "round": round_index,
875
+ "phase": "llm_stream",
876
+ "kind": kind,
877
+ "provider": error.provider,
878
+ "statusCode": error.status_code,
879
+ "retryAfterMs": error.retry_after_ms,
880
+ }),
734
881
  ));
882
+ if !content.trim().is_empty() && tool_calls.is_empty() {
883
+ history.push(LLMMessage::text("assistant", content.clone()));
884
+ }
885
+ self.observe_history(&history);
735
886
  emit(completion_event(
736
887
  round_index,
737
888
  "stop",
738
889
  &content,
739
890
  &tool_calls,
740
891
  ctx.consecutive_tool_errors,
741
- "budget_exhausted",
742
- "token budget exceeded",
743
- 1.0,
892
+ "failed",
893
+ failure_reason,
894
+ 0.9,
744
895
  &ctx,
745
896
  ));
746
- token_exhausted = true;
747
- break;
897
+ stream_failed = true;
898
+ break 'stream_attempt;
748
899
  }
749
900
  }
750
901
  }
751
- if self.poll(false).cancel {
752
- stream_cancelled = true;
753
- break;
754
- }
755
- if !stream_has_tool && !chunk_has_tool {
756
- let delta_chars = chunk_content_chars.saturating_add(chunk_reasoning_chars);
757
- stream_chars = stream_chars.saturating_add(delta_chars);
758
- let stale_chars = ctx.reasoning_since_progress.saturating_add(stream_chars);
759
- let trigger = if self
760
- .config
761
- .idle_stream_timeout_ms
762
- .is_some_and(|limit| stream_active_ms >= limit)
763
- {
764
- Some("active_ms")
765
- } else if self
766
- .config
767
- .idle_stream_max_chars
768
- .is_some_and(|limit| stream_chars >= limit)
769
- {
770
- Some("chars")
771
- } else if self
772
- .config
773
- .reasoning_without_progress_chars
774
- .is_some_and(|limit| stale_chars >= limit)
775
- {
776
- Some("no_progress")
777
- } else {
778
- None
779
- };
780
- if let Some(trigger) = trigger {
781
- if trigger == "no_progress" {
782
- ctx.reasoning_since_progress = 0;
783
- } else {
784
- ctx.reasoning_since_progress = stale_chars;
785
- }
786
- stream_progress_accounted = true;
787
- stream_idle_cut = Some((trigger.to_string(), stream_chars, stale_chars));
788
- break;
789
- }
790
- }
791
- if chunk_has_tool {
792
- stream_has_tool = true;
793
- }
794
- }
795
- if !stream_progress_accounted && tool_calls.is_empty() {
796
- ctx.reasoning_since_progress =
797
- ctx.reasoning_since_progress.saturating_add(stream_chars);
798
- }
799
902
 
800
- if stream_provider_error.is_none() {
801
903
  emit(LoopEvent::new(
802
904
  "llm_response",
803
905
  json!({
@@ -809,57 +911,13 @@ impl<P: LLMProvider, E: ToolExecutor, H: LoopHooks> CoreLoop<P, E, H> {
809
911
  "cancelled": stream_cancelled,
810
912
  }),
811
913
  ));
812
- }
813
-
814
- if let Some(error) = stream_provider_error {
815
- let message = error.message.clone();
816
- let kind = error.kind.as_str();
817
- emit(LoopEvent::new(
818
- "llm_response",
819
- json!({
820
- "round": round_index,
821
- "attempt": llm_attempt,
822
- "durationMs": llm_started.elapsed().as_millis(),
823
- "error": message,
824
- "errorKind": kind,
825
- }),
826
- ));
827
- let reason = match self
828
- .hooks
829
- .on_request_error(&error, &history, round_index)
830
- .await
831
- {
832
- RequestErrorAction::Fail { reason } => reason,
833
- RequestErrorAction::Retry { reason, .. } if !reason.is_empty() => reason,
834
- RequestErrorAction::Retry { .. } => format!("llm stream error: {message}"),
835
- };
836
- emit(LoopEvent::new(
837
- "error",
838
- json!({
839
- "message": message,
840
- "round": round_index,
841
- "phase": "llm_stream",
842
- "kind": kind,
843
- "provider": error.provider,
844
- "statusCode": error.status_code,
845
- "retryAfterMs": error.retry_after_ms,
846
- }),
847
- ));
848
- if !content.trim().is_empty() && tool_calls.is_empty() {
849
- history.push(LLMMessage::text("assistant", content.clone()));
914
+ if !stream_progress_accounted && tool_calls.is_empty() {
915
+ ctx.reasoning_since_progress =
916
+ ctx.reasoning_since_progress.saturating_add(stream_chars);
850
917
  }
851
- self.observe_history(&history);
852
- emit(completion_event(
853
- round_index,
854
- "stop",
855
- &content,
856
- &tool_calls,
857
- ctx.consecutive_tool_errors,
858
- "failed",
859
- reason,
860
- 0.9,
861
- &ctx,
862
- ));
918
+ break 'stream_attempt;
919
+ }
920
+ if stream_failed {
863
921
  break;
864
922
  }
865
923
 
@@ -6,8 +6,8 @@ use serde_json::{json, Value};
6
6
  use steerable_agent_runtime::{
7
7
  chunk_stream, BudgetLimit, CompletionDraft, ContentPart, CoreLoop, HookAction, LLMChunkStream,
8
8
  LLMError, LLMErrorKind, LLMMessage, LLMProvider, LLMStreamChunk, LLMUsage, LoopConfig,
9
- LoopContext, LoopEvent, LoopHooks, RoundControl, RouterToolExecutor, ScriptedProvider,
10
- ScriptedTurn, ToolCall, ToolExecutor, ToolResult, ToolRouter,
9
+ LoopContext, LoopEvent, LoopHooks, RequestErrorAction, RoundControl, RouterToolExecutor,
10
+ ScriptedProvider, ScriptedTurn, ToolCall, ToolExecutor, ToolResult, ToolRouter,
11
11
  };
12
12
 
13
13
  fn provider(scripts: Vec<ScriptedTurn>) -> ScriptedProvider {
@@ -684,6 +684,125 @@ async fn test_provider_error_emits_error_and_failed_completion() {
684
684
  assert_eq!(events[4].data["reason"], "failing: HTTP 401 (auth)");
685
685
  }
686
686
 
687
+ struct MidStreamThenOk {
688
+ attempts: usize,
689
+ }
690
+
691
+ #[async_trait]
692
+ impl LLMProvider for MidStreamThenOk {
693
+ fn name(&self) -> &str {
694
+ "mid-stream"
695
+ }
696
+
697
+ fn model(&self) -> &str {
698
+ "mid-stream"
699
+ }
700
+
701
+ async fn stream(&mut self, _messages: &[LLMMessage]) -> Result<LLMChunkStream, LLMError> {
702
+ self.attempts += 1;
703
+ if self.attempts == 1 {
704
+ return Ok(Box::pin(async_stream::stream! {
705
+ yield Ok(LLMStreamChunk {
706
+ content_delta: Some("partial".into()),
707
+ ..LLMStreamChunk::default()
708
+ });
709
+ yield Err(LLMError::new(
710
+ "openai_compat: transport error: incomplete chunked read",
711
+ LLMErrorKind::Transport,
712
+ ));
713
+ }));
714
+ }
715
+ Ok(chunk_stream(vec![
716
+ LLMStreamChunk {
717
+ content_delta: Some("recovered after retry".into()),
718
+ ..LLMStreamChunk::default()
719
+ },
720
+ LLMStreamChunk {
721
+ finish_reason: Some("stop".into()),
722
+ ..LLMStreamChunk::default()
723
+ },
724
+ ]))
725
+ }
726
+ }
727
+
728
+ struct RetryRequestError;
729
+
730
+ #[async_trait]
731
+ impl LoopHooks for RetryRequestError {
732
+ async fn on_request_error(
733
+ &self,
734
+ _error: &LLMError,
735
+ _transcript: &[LLMMessage],
736
+ _round_index: u32,
737
+ ) -> RequestErrorAction {
738
+ RequestErrorAction::Retry {
739
+ delay_ms: 0,
740
+ reason: String::new(),
741
+ rewrite: None,
742
+ }
743
+ }
744
+ }
745
+
746
+ #[tokio::test]
747
+ async fn test_mid_stream_transport_error_retries_then_recovers() {
748
+ let mut agent = CoreLoop::new(
749
+ MidStreamThenOk { attempts: 0 },
750
+ RouterToolExecutor::new(ToolRouter::new()),
751
+ )
752
+ .with_hooks(RetryRequestError);
753
+
754
+ let events = agent.run(vec![LLMMessage::text("user", "go")]).await;
755
+
756
+ assert_eq!(final_completion(&events)["status"], "completed");
757
+ let retry = events
758
+ .iter()
759
+ .find(|event| {
760
+ event.kind == "hook_action"
761
+ && event.data["hook"] == "on_request_error"
762
+ && event.data["action"] == "retry"
763
+ })
764
+ .expect("mid-stream transport should emit on_request_error retry");
765
+ assert!(retry.data["reason"]
766
+ .as_str()
767
+ .is_some_and(|reason| reason.contains("incomplete chunked read")));
768
+ assert!(!events.iter().any(|event| event.kind == "error"));
769
+ let assistant = agent
770
+ .last_history
771
+ .iter()
772
+ .rev()
773
+ .find(|message| message.role == "assistant")
774
+ .expect("retry should record the recovered assistant text");
775
+ assert_eq!(assistant.content_text(), "recovered after retry");
776
+ }
777
+
778
+ #[tokio::test]
779
+ async fn test_mid_stream_error_keeps_partial_text_when_hook_fails() {
780
+ let mut agent = CoreLoop::new(
781
+ MidStreamThenOk { attempts: 0 },
782
+ RouterToolExecutor::new(ToolRouter::new()),
783
+ );
784
+
785
+ let events = agent.run(vec![LLMMessage::text("user", "go")]).await;
786
+
787
+ assert_eq!(
788
+ events
789
+ .iter()
790
+ .filter(|event| event.kind == "content_delta")
791
+ .filter_map(|event| event.data["delta"].as_str())
792
+ .collect::<Vec<_>>(),
793
+ vec!["partial"]
794
+ );
795
+ assert_eq!(final_completion(&events)["status"], "failed");
796
+ assert_eq!(final_completion(&events)["textLength"], 7);
797
+ let assistant = agent
798
+ .last_history
799
+ .iter()
800
+ .rev()
801
+ .find(|message| message.role == "assistant")
802
+ .expect("failed mid-stream should keep partial assistant text");
803
+ assert_eq!(assistant.content_text(), "partial");
804
+ }
805
+
687
806
  struct SlowToolExecutor;
688
807
 
689
808
  #[async_trait]