steerable-agent-runtime-native 0.6.32__tar.gz → 0.6.33__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.32 → steerable_agent_runtime_native-0.6.33}/Cargo.lock +1 -1
  2. {steerable_agent_runtime_native-0.6.32 → steerable_agent_runtime_native-0.6.33}/Cargo.toml +1 -1
  3. {steerable_agent_runtime_native-0.6.32 → steerable_agent_runtime_native-0.6.33}/PKG-INFO +1 -1
  4. {steerable_agent_runtime_native-0.6.32 → steerable_agent_runtime_native-0.6.33}/pyproject.toml +1 -1
  5. {steerable_agent_runtime_native-0.6.32 → steerable_agent_runtime_native-0.6.33}/src/engine.rs +76 -52
  6. {steerable_agent_runtime_native-0.6.32 → steerable_agent_runtime_native-0.6.33}/src/hooks.rs +23 -0
  7. {steerable_agent_runtime_native-0.6.32 → steerable_agent_runtime_native-0.6.33}/src/lib.rs +2 -1
  8. {steerable_agent_runtime_native-0.6.32 → steerable_agent_runtime_native-0.6.33}/src/python.rs +32 -1
  9. {steerable_agent_runtime_native-0.6.32 → steerable_agent_runtime_native-0.6.33}/tests/test_loop.rs +87 -4
  10. {steerable_agent_runtime_native-0.6.32 → steerable_agent_runtime_native-0.6.33}/.gitignore +0 -0
  11. {steerable_agent_runtime_native-0.6.32 → steerable_agent_runtime_native-0.6.33}/README.md +0 -0
  12. {steerable_agent_runtime_native-0.6.32 → steerable_agent_runtime_native-0.6.33}/src/anthropic_http.rs +0 -0
  13. {steerable_agent_runtime_native-0.6.32 → steerable_agent_runtime_native-0.6.33}/src/anthropic_wire.rs +0 -0
  14. {steerable_agent_runtime_native-0.6.32 → steerable_agent_runtime_native-0.6.33}/src/budget.rs +0 -0
  15. {steerable_agent_runtime_native-0.6.32 → steerable_agent_runtime_native-0.6.33}/src/compat.rs +0 -0
  16. {steerable_agent_runtime_native-0.6.32 → steerable_agent_runtime_native-0.6.33}/src/errors.rs +0 -0
  17. {steerable_agent_runtime_native-0.6.32 → steerable_agent_runtime_native-0.6.33}/src/executor.rs +0 -0
  18. {steerable_agent_runtime_native-0.6.32 → steerable_agent_runtime_native-0.6.33}/src/gemini_http.rs +0 -0
  19. {steerable_agent_runtime_native-0.6.32 → steerable_agent_runtime_native-0.6.33}/src/gemini_wire.rs +0 -0
  20. {steerable_agent_runtime_native-0.6.32 → steerable_agent_runtime_native-0.6.33}/src/json_py.rs +0 -0
  21. {steerable_agent_runtime_native-0.6.32 → steerable_agent_runtime_native-0.6.33}/src/model_catalog.json +0 -0
  22. {steerable_agent_runtime_native-0.6.32 → steerable_agent_runtime_native-0.6.33}/src/model_resolve.rs +0 -0
  23. {steerable_agent_runtime_native-0.6.32 → steerable_agent_runtime_native-0.6.33}/src/openai_http.rs +0 -0
  24. {steerable_agent_runtime_native-0.6.32 → steerable_agent_runtime_native-0.6.33}/src/openai_wire.rs +0 -0
  25. {steerable_agent_runtime_native-0.6.32 → steerable_agent_runtime_native-0.6.33}/src/presets.rs +0 -0
  26. {steerable_agent_runtime_native-0.6.32 → steerable_agent_runtime_native-0.6.33}/src/pricing.rs +0 -0
  27. {steerable_agent_runtime_native-0.6.32 → steerable_agent_runtime_native-0.6.33}/src/provider.rs +0 -0
  28. {steerable_agent_runtime_native-0.6.32 → steerable_agent_runtime_native-0.6.33}/src/ptc_js.rs +0 -0
  29. {steerable_agent_runtime_native-0.6.32 → steerable_agent_runtime_native-0.6.33}/src/responses_http.rs +0 -0
  30. {steerable_agent_runtime_native-0.6.32 → steerable_agent_runtime_native-0.6.33}/src/responses_wire.rs +0 -0
  31. {steerable_agent_runtime_native-0.6.32 → steerable_agent_runtime_native-0.6.33}/src/run_code.rs +0 -0
  32. {steerable_agent_runtime_native-0.6.32 → steerable_agent_runtime_native-0.6.33}/src/skills.rs +0 -0
  33. {steerable_agent_runtime_native-0.6.32 → steerable_agent_runtime_native-0.6.33}/src/sse_http.rs +0 -0
  34. {steerable_agent_runtime_native-0.6.32 → steerable_agent_runtime_native-0.6.33}/src/todo.rs +0 -0
  35. {steerable_agent_runtime_native-0.6.32 → steerable_agent_runtime_native-0.6.33}/src/tokens.rs +0 -0
  36. {steerable_agent_runtime_native-0.6.32 → steerable_agent_runtime_native-0.6.33}/src/types.rs +0 -0
  37. {steerable_agent_runtime_native-0.6.32 → steerable_agent_runtime_native-0.6.33}/src/web.rs +0 -0
  38. {steerable_agent_runtime_native-0.6.32 → steerable_agent_runtime_native-0.6.33}/src/web_html.rs +0 -0
  39. {steerable_agent_runtime_native-0.6.32 → steerable_agent_runtime_native-0.6.33}/src/web_search.rs +0 -0
  40. {steerable_agent_runtime_native-0.6.32 → steerable_agent_runtime_native-0.6.33}/src/wire.rs +0 -0
  41. {steerable_agent_runtime_native-0.6.32 → steerable_agent_runtime_native-0.6.33}/src/write_lease.rs +0 -0
  42. {steerable_agent_runtime_native-0.6.32 → steerable_agent_runtime_native-0.6.33}/tests/test_llm_wire.rs +0 -0
  43. {steerable_agent_runtime_native-0.6.32 → steerable_agent_runtime_native-0.6.33}/tests/test_ptc_js.rs +0 -0
  44. {steerable_agent_runtime_native-0.6.32 → steerable_agent_runtime_native-0.6.33}/tests/test_run_code.rs +0 -0
  45. {steerable_agent_runtime_native-0.6.32 → steerable_agent_runtime_native-0.6.33}/tests/test_todo.rs +0 -0
  46. {steerable_agent_runtime_native-0.6.32 → steerable_agent_runtime_native-0.6.33}/tests/test_web.rs +0 -0
  47. {steerable_agent_runtime_native-0.6.32 → steerable_agent_runtime_native-0.6.33}/tests/test_write_lease.rs +0 -0
  48. {steerable_agent_runtime_native-0.6.32 → steerable_agent_runtime_native-0.6.33}/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.32"
958
+ version = "0.6.33"
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.32"
3
+ version = "0.6.33"
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.32
3
+ Version: 0.6.33
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.32"
7
+ version = "0.6.33"
8
8
  description = "PyO3 bindings for the Rust CoreLoop."
9
9
  requires-python = ">=3.10"
10
10
 
@@ -387,60 +387,79 @@ impl<P: LLMProvider, E: ToolExecutor, H: LoopHooks> CoreLoop<P, E, H> {
387
387
  }
388
388
  }
389
389
 
390
- let step_tool_choice;
391
- match self.hooks.pre_step(&history).await {
392
- PreStepOutcome::Reject { reason } => {
393
- emit(completion_event(
394
- round_index,
395
- "stop",
396
- "",
397
- &[],
398
- ctx.consecutive_tool_errors,
399
- "failed",
400
- reason,
401
- 1.0,
402
- &ctx,
403
- ));
404
- break;
405
- }
406
- PreStepOutcome::Proceed {
407
- appends,
408
- rewrite,
409
- reason,
410
- action,
411
- pre_tokens,
412
- post_tokens,
413
- tool_choice,
414
- } => {
415
- if let Some(replacement) = rewrite {
416
- history = replacement;
417
- emit(LoopEvent::new(
418
- "hook_action",
419
- json!({
420
- "hook": "pre_step",
421
- "action": action.clone(),
422
- "reason": reason.clone(),
423
- "round": round_index,
424
- "pre_tokens": pre_tokens,
425
- "post_tokens": post_tokens,
426
- }),
390
+ let (step_tool_choice, step_tool_choice_honored) =
391
+ match self.hooks.pre_step(&history).await {
392
+ PreStepOutcome::Reject { reason } => {
393
+ emit(completion_event(
394
+ round_index,
395
+ "stop",
396
+ "",
397
+ &[],
398
+ ctx.consecutive_tool_errors,
399
+ "failed",
400
+ reason,
401
+ 1.0,
402
+ &ctx,
427
403
  ));
404
+ break;
428
405
  }
429
- for message in appends {
430
- history.push(message);
431
- emit(LoopEvent::new(
432
- "hook_action",
433
- json!({
434
- "hook": "pre_step",
435
- "action": action.clone(),
436
- "reason": reason.clone(),
437
- "round": round_index,
438
- }),
439
- ));
406
+ PreStepOutcome::Proceed {
407
+ appends,
408
+ rewrite,
409
+ reason,
410
+ action,
411
+ pre_tokens,
412
+ post_tokens,
413
+ tool_choice,
414
+ tool_choice_honored,
415
+ notes,
416
+ } => {
417
+ if let Some(replacement) = rewrite {
418
+ history = replacement;
419
+ emit(LoopEvent::new(
420
+ "hook_action",
421
+ json!({
422
+ "hook": "pre_step",
423
+ "action": action.clone(),
424
+ "reason": reason.clone(),
425
+ "round": round_index,
426
+ "pre_tokens": pre_tokens,
427
+ "post_tokens": post_tokens,
428
+ }),
429
+ ));
430
+ }
431
+ for message in appends {
432
+ history.push(message);
433
+ emit(LoopEvent::new(
434
+ "hook_action",
435
+ json!({
436
+ "hook": "pre_step",
437
+ "action": action.clone(),
438
+ "reason": reason.clone(),
439
+ "round": round_index,
440
+ }),
441
+ ));
442
+ }
443
+ // Verdicts that took no action. Without these a trace
444
+ // shows only the rounds a hook intervened in, which is a
445
+ // numerator with no denominator — "routing allowed a
446
+ // no-tool turn" and "routing never ran" look identical.
447
+ for note in notes {
448
+ emit(LoopEvent::new(
449
+ "hook_action",
450
+ json!({
451
+ "hook": "pre_step",
452
+ "action": note.action,
453
+ "reason": note.reason,
454
+ "value": note.value,
455
+ "probability": note.probability,
456
+ "round": round_index,
457
+ }),
458
+ ));
459
+ }
460
+ (tool_choice, tool_choice_honored)
440
461
  }
441
- step_tool_choice = tool_choice;
442
- }
443
- }
462
+ };
444
463
 
445
464
  if let Some(tool_choice) = &step_tool_choice {
446
465
  emit(LoopEvent::new(
@@ -448,7 +467,12 @@ impl<P: LLMProvider, E: ToolExecutor, H: LoopHooks> CoreLoop<P, E, H> {
448
467
  json!({
449
468
  "hook": "pre_step",
450
469
  "action": "tool_choice",
451
- "toolChoice": tool_choice,
470
+ "value": tool_choice,
471
+ // Several vendors downgrade `required` to `auto` on the
472
+ // wire. Logging the hook without this reads as a gate
473
+ // that held, so measurements over forced turns
474
+ // silently mix the models where the gate is a no-op.
475
+ "honored": step_tool_choice_honored.unwrap_or(true),
452
476
  "round": round_index,
453
477
  }),
454
478
  ));
@@ -21,6 +21,22 @@ pub enum HookAction {
21
21
  Narrate { message: String, reason: String },
22
22
  }
23
23
 
24
+ /// A hook verdict the engine records even though it changed nothing about the
25
+ /// step.
26
+ ///
27
+ /// Verdicts that act are already visible as `hook_action` (a rewrite, an
28
+ /// append, a `tool_choice`). The verdict that declined to act leaves no trace,
29
+ /// so a trajectory cannot distinguish "the data-need router allowed a no-tool
30
+ /// turn" from "routing never ran" — the denominator a routing-variance
31
+ /// measurement needs.
32
+ #[derive(Clone, Debug, PartialEq)]
33
+ pub struct DecisionNote {
34
+ pub action: String,
35
+ pub reason: String,
36
+ pub value: Option<String>,
37
+ pub probability: Option<f64>,
38
+ }
39
+
24
40
  #[derive(Clone, Debug, PartialEq)]
25
41
  pub enum PreStepOutcome {
26
42
  Proceed {
@@ -31,6 +47,11 @@ pub enum PreStepOutcome {
31
47
  pre_tokens: Option<i64>,
32
48
  post_tokens: Option<i64>,
33
49
  tool_choice: Option<String>,
50
+ /// Whether `tool_choice` survives to the wire, when the hook's host
51
+ /// could answer. `None` means unknown and is recorded as honored:
52
+ /// only the provider adapter knows about vendor downgrades.
53
+ tool_choice_honored: Option<bool>,
54
+ notes: Vec<DecisionNote>,
34
55
  },
35
56
  Reject {
36
57
  reason: String,
@@ -59,6 +80,8 @@ impl Default for PreStepOutcome {
59
80
  pre_tokens: None,
60
81
  post_tokens: None,
61
82
  tool_choice: None,
83
+ tool_choice_honored: None,
84
+ notes: Vec::new(),
62
85
  }
63
86
  }
64
87
  }
@@ -52,7 +52,8 @@ pub use gemini_wire::{
52
52
  encode_gemini_contents, parse_gemini_chunk, parse_gemini_usage, GoogleGenAIProvider,
53
53
  };
54
54
  pub use hooks::{
55
- CompletionDraft, HookAction, LoopHooks, NoopHooks, PreStepOutcome, RequestErrorAction,
55
+ CompletionDraft, DecisionNote, HookAction, LoopHooks, NoopHooks, PreStepOutcome,
56
+ RequestErrorAction,
56
57
  };
57
58
  pub use model_resolve::{
58
59
  catalog_provider_for_base_url, describe_catalog_providers, resolve_in_catalog,
@@ -10,7 +10,9 @@ use serde_json::Value;
10
10
  use crate::engine::{CoreLoop, LoopConfig, RoundControl};
11
11
  use crate::errors::{LLMError, LLMErrorKind};
12
12
  use crate::executor::ToolExecutor;
13
- use crate::hooks::{CompletionDraft, HookAction, LoopHooks, PreStepOutcome, RequestErrorAction};
13
+ use crate::hooks::{
14
+ CompletionDraft, DecisionNote, HookAction, LoopHooks, PreStepOutcome, RequestErrorAction,
15
+ };
14
16
  use crate::provider::{LLMChunkStream, LLMProvider};
15
17
  use crate::types::{LLMMessage, LoopContext, LoopEvent, ToolCall, ToolResult};
16
18
  use crate::wire::{
@@ -25,6 +27,33 @@ struct CallbackProvider {
25
27
  next_stream_chunk: Py<PyAny>,
26
28
  }
27
29
 
30
+ /// Decode the pre_step payload's `notes`. A malformed entry is skipped rather
31
+ /// than failing the step: these describe a decision that already happened, so
32
+ /// losing one costs a trace line and nothing else.
33
+ fn decision_notes_from_value(raw: Option<&Value>) -> Vec<DecisionNote> {
34
+ let Some(items) = raw.and_then(Value::as_array) else {
35
+ return Vec::new();
36
+ };
37
+ items
38
+ .iter()
39
+ .filter_map(|item| {
40
+ Some(DecisionNote {
41
+ action: item.get("action").and_then(Value::as_str)?.to_string(),
42
+ reason: item
43
+ .get("reason")
44
+ .and_then(Value::as_str)
45
+ .unwrap_or_default()
46
+ .to_string(),
47
+ value: item
48
+ .get("value")
49
+ .and_then(Value::as_str)
50
+ .map(str::to_string),
51
+ probability: item.get("probability").and_then(Value::as_f64),
52
+ })
53
+ })
54
+ .collect()
55
+ }
56
+
28
57
  fn callback_llm_error(value: &Value, default_message: &str) -> LLMError {
29
58
  let kind = match value
30
59
  .get("error_kind")
@@ -335,6 +364,8 @@ impl LoopHooks for CallbackHooks {
335
364
  .get("tool_choice")
336
365
  .and_then(Value::as_str)
337
366
  .map(str::to_string),
367
+ tool_choice_honored: value.get("tool_choice_honored").and_then(Value::as_bool),
368
+ notes: decision_notes_from_value(value.get("notes")),
338
369
  }
339
370
  }
340
371
 
@@ -4,10 +4,11 @@ use async_trait::async_trait;
4
4
  use pretty_assertions::assert_eq;
5
5
  use serde_json::{json, Value};
6
6
  use steerable_agent_runtime::{
7
- chunk_stream, BudgetLimit, CompletionDraft, ContentPart, CoreLoop, HookAction, LLMChunkStream,
8
- LLMError, LLMErrorKind, LLMMessage, LLMProvider, LLMStreamChunk, LLMUsage, LoopConfig,
9
- LoopContext, LoopEvent, LoopHooks, RequestErrorAction, RoundControl, RouterToolExecutor,
10
- ScriptedProvider, ScriptedTurn, ToolCall, ToolExecutor, ToolResult, ToolRouter,
7
+ chunk_stream, BudgetLimit, CompletionDraft, ContentPart, CoreLoop, DecisionNote, HookAction,
8
+ LLMChunkStream, LLMError, LLMErrorKind, LLMMessage, LLMProvider, LLMStreamChunk, LLMUsage,
9
+ LoopConfig, LoopContext, LoopEvent, LoopHooks, PreStepOutcome, RequestErrorAction,
10
+ RoundControl, RouterToolExecutor, ScriptedProvider, ScriptedTurn, ToolCall, ToolExecutor,
11
+ ToolResult, ToolRouter,
11
12
  };
12
13
 
13
14
  fn provider(scripts: Vec<ScriptedTurn>) -> ScriptedProvider {
@@ -930,6 +931,88 @@ async fn test_dedup_ignores_argument_key_order_and_carries_guidance() {
930
931
  .contains("do not repeat the same call"));
931
932
  }
932
933
 
934
+ struct RouteVerdict {
935
+ verdict: &'static str,
936
+ honored: Option<bool>,
937
+ }
938
+
939
+ #[async_trait]
940
+ impl LoopHooks for RouteVerdict {
941
+ async fn pre_step(&self, _transcript: &[LLMMessage]) -> PreStepOutcome {
942
+ let forcing = self.verdict == "require_tool";
943
+ PreStepOutcome::Proceed {
944
+ appends: Vec::new(),
945
+ rewrite: None,
946
+ reason: None,
947
+ action: "append".into(),
948
+ pre_tokens: None,
949
+ post_tokens: None,
950
+ tool_choice: forcing.then(|| "required".to_string()),
951
+ tool_choice_honored: self.honored,
952
+ notes: vec![DecisionNote {
953
+ action: "data_need_route".into(),
954
+ reason: "anti-hallucination data-need routing".into(),
955
+ value: Some(self.verdict.into()),
956
+ probability: None,
957
+ }],
958
+ }
959
+ }
960
+ }
961
+
962
+ #[tokio::test]
963
+ async fn test_pre_step_notes_record_a_verdict_that_changed_nothing() {
964
+ let mut agent = CoreLoop::new(
965
+ provider(vec![text_turn("boiling point is 100C")]),
966
+ RouterToolExecutor::new(ToolRouter::new()),
967
+ )
968
+ .with_hooks(RouteVerdict {
969
+ verdict: "allow_no_tool",
970
+ honored: None,
971
+ });
972
+
973
+ let events = agent
974
+ .run(vec![LLMMessage::text("user", "boiling point?")])
975
+ .await;
976
+
977
+ let note = events
978
+ .iter()
979
+ .find(|event| event.kind == "hook_action" && event.data["action"] == "data_need_route")
980
+ .expect("a verdict that took no action must still be recorded");
981
+ assert_eq!(note.data["value"], json!("allow_no_tool"));
982
+ assert_eq!(note.data["hook"], json!("pre_step"));
983
+ // Nothing was forced, so there is no tool_choice event to confuse it with.
984
+ assert!(!events
985
+ .iter()
986
+ .any(|event| event.kind == "hook_action" && event.data["action"] == "tool_choice"));
987
+ }
988
+
989
+ #[tokio::test]
990
+ async fn test_tool_choice_event_reports_a_vendor_downgrade() {
991
+ let mut router = ToolRouter::new();
992
+ router.register("get_data", |_| async {
993
+ Ok(ToolResult::ok(json!({ "value": 42 })))
994
+ });
995
+ let mut agent = CoreLoop::new(
996
+ provider(vec![text_turn("42")]),
997
+ RouterToolExecutor::new(router),
998
+ )
999
+ .with_hooks(RouteVerdict {
1000
+ verdict: "require_tool",
1001
+ honored: Some(false),
1002
+ });
1003
+
1004
+ let events = agent.run(vec![LLMMessage::text("user", "fetch it")]).await;
1005
+
1006
+ let forced = events
1007
+ .iter()
1008
+ .find(|event| event.kind == "hook_action" && event.data["action"] == "tool_choice")
1009
+ .expect("a forced step must record its gate");
1010
+ assert_eq!(forced.data["value"], json!("required"));
1011
+ // The gate never reached the wire; recording it as honored would make every
1012
+ // measurement over forced turns silently mix in models that ignore it.
1013
+ assert_eq!(forced.data["honored"], json!(false));
1014
+ }
1015
+
933
1016
  struct NarrateOnFailure;
934
1017
 
935
1018
  #[async_trait]