tinyagents 2.1.0

A recursive language-model (RLM) harness for Rust.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
//! End-to-end coverage for two harness features:
//!
//! **Part A — reasoning streaming.** The [`MessageDelta`] reasoning channel and
//! [`StreamAccumulator`] keep thinking output on a side channel that never
//! leaks into the final visible text. These tests drive a [`StreamingMock`]
//! through the real [`ChatModel::stream`] path, fold the items with a
//! [`StreamAccumulator`], and assert the reasoning/text split, plus the serde
//! shape of [`MessageDelta`].
//!
//! **Part B — contextual tool selection.** [`ContextualToolSelectionMiddleware`]
//! filters the tools the model is shown, either from allow/deny lists or from a
//! context-aware predicate that can vary exposure by run depth. These tests run
//! a full harness against a [`ScriptedModel`] and inspect the recorded
//! request's tool list to observe exactly what the model saw after filtering.

use std::sync::Arc;

use async_trait::async_trait;
use futures::StreamExt;

use tinyagents::harness::context::{RunConfig, RunContext};
use tinyagents::harness::events::AgentEvent;
use tinyagents::harness::message::{AssistantMessage, ContentBlock, Message, MessageDelta};
use tinyagents::harness::middleware::{ContextualToolSelectionMiddleware, ToolSelectionContext};
use tinyagents::harness::model::{
    ChatModel, ModelProfile, ModelRegistry, ModelRequest, ModelResolutionSource, ModelResponse,
    ModelSelection, ModelStatus, ModelStreamItem, StreamAccumulator,
};
use tinyagents::harness::runtime::AgentHarness;
use tinyagents::harness::testkit::{EventRecorder, FakeTool, ScriptedModel, StreamingMock};
use tinyagents::harness::tool::ToolSchema;
use tinyagents::harness::usage::Usage;

/// Builds a plain-text [`ModelResponse`] so a [`ScriptedModel`] can answer a run
/// in a single model call (no tool execution needed).
fn text_response(text: &str) -> ModelResponse {
    ModelResponse {
        message: AssistantMessage {
            id: None,
            content: vec![ContentBlock::Text(text.into())],
            tool_calls: Vec::new(),
            usage: Some(Usage::new(3, 1)),
        },
        usage: Some(Usage::new(3, 1)),
        finish_reason: Some("stop".into()),
        raw: None,
        resolved_model: None,
    }
}

/// Builds the authoritative `Completed` response carrying only visible text.
fn completed_text(text: &str) -> ModelStreamItem {
    ModelStreamItem::Completed(text_response(text))
}

// ---------------------------------------------------------------------------
// Part A — reasoning streaming
// ---------------------------------------------------------------------------

/// Reasoning fragments accumulate on the side channel and stay out of the final
/// visible text, which comes from the authoritative `Completed` response.
#[tokio::test]
async fn streamed_reasoning_stays_out_of_final_text() {
    let items = vec![
        ModelStreamItem::Started,
        ModelStreamItem::MessageDelta(MessageDelta::reasoning("think ")),
        ModelStreamItem::MessageDelta(MessageDelta::text("Hello")),
        ModelStreamItem::MessageDelta(MessageDelta::reasoning("harder")),
        ModelStreamItem::MessageDelta(MessageDelta {
            text: ", world".into(),
            reasoning: "!".into(),
            tool_call: None,
        }),
        completed_text("Hello, world"),
    ];

    let model = StreamingMock::new(items);
    let request = ModelRequest::new(vec![Message::user("hi")]);

    let mut stream = ChatModel::<()>::stream(&model, &(), request)
        .await
        .expect("opening the mock stream succeeds");

    let mut accumulator = StreamAccumulator::new();
    let mut delta_count = 0usize;
    while let Some(item) = stream.next().await {
        if matches!(item, ModelStreamItem::MessageDelta(_)) {
            delta_count += 1;
        }
        accumulator.push(&item);
    }

    assert_eq!(delta_count, 4, "four message deltas were streamed");
    assert!(
        accumulator.is_terminal(),
        "the Completed item marks the accumulator terminal"
    );
    // Reasoning is the concatenation of every reasoning fragment, in order.
    assert_eq!(accumulator.reasoning(), "think harder!");

    let response = accumulator.finish().expect("stream merges into a response");
    // The visible text comes from the authoritative Completed response and does
    // NOT include any reasoning fragments.
    assert_eq!(response.text(), "Hello, world");
    assert!(
        !response.text().contains("think"),
        "reasoning must never leak into the final visible text"
    );
}

/// A `MessageDelta` round-trips through serde unchanged, and a text-only delta
/// omits the empty `reasoning` field entirely (`skip_serializing_if`).
#[test]
fn message_delta_serde_round_trip_and_field_skipping() {
    let delta = MessageDelta {
        text: "a".into(),
        reasoning: "b".into(),
        tool_call: None,
    };
    let json = serde_json::to_string(&delta).expect("serialize");
    let back: MessageDelta = serde_json::from_str(&json).expect("deserialize");
    assert_eq!(delta, back, "round-trip preserves every field");
    assert!(json.contains("\"reasoning\":\"b\""));

    // A text-only delta has an empty reasoning field, which is skipped in JSON.
    let text_only = MessageDelta::text("visible");
    let text_json = serde_json::to_string(&text_only).expect("serialize");
    assert!(
        !text_json.contains("reasoning"),
        "empty reasoning is skipped, got {text_json}"
    );
    assert!(text_json.contains("\"text\":\"visible\""));
}

/// The two named constructors populate exactly one channel each.
#[test]
fn message_delta_constructors_populate_one_channel() {
    let reasoning = MessageDelta::reasoning("x");
    assert_eq!(reasoning.reasoning, "x");
    assert!(reasoning.text.is_empty());
    assert!(reasoning.tool_call.is_none());

    let text = MessageDelta::text("y");
    assert_eq!(text.text, "y");
    assert!(text.reasoning.is_empty());
    assert!(text.tool_call.is_none());
}

// ---------------------------------------------------------------------------
// Part B — contextual tool selection
// ---------------------------------------------------------------------------

/// Runs a harness with the given middleware at the given depth and returns the
/// tool names the model was shown (after filtering), sorted for stable
/// assertions.
async fn exposed_tools_at_depth(
    middleware: Arc<ContextualToolSelectionMiddleware>,
    depth: usize,
) -> Vec<String> {
    let scripted = Arc::new(ScriptedModel::new(vec![text_response("done")]));

    let mut harness: AgentHarness<()> = AgentHarness::new();
    harness.register_model("mock", scripted.clone());
    harness.register_tool(Arc::new(FakeTool::returning("a", "ok")));
    harness.register_tool(Arc::new(FakeTool::returning("b", "ok")));
    harness.register_tool(Arc::new(FakeTool::returning("c", "ok")));
    harness.register_tool(Arc::new(FakeTool::returning("privileged", "ok")));
    harness.register_tool(Arc::new(FakeTool::returning("safe", "ok")));
    harness.push_middleware(middleware);

    let ctx = RunContext::new(RunConfig::new("run").with_depth(depth), ());
    harness
        .invoke_in_context(&(), ctx, vec![Message::user("go")])
        .await
        .expect("the run completes with a single scripted text response");

    let requests = scripted.requests();
    assert_eq!(requests.len(), 1, "exactly one model call was recorded");
    let mut names: Vec<String> = requests[0].tools.iter().map(|t| t.name.clone()).collect();
    names.sort();
    names
}

/// `from_lists(Some([a, b]), [b])`: deny removes `b`, and the allow-list is
/// fail-closed so unknown tools (`c`, `privileged`, `safe`) are excluded too —
/// the model is shown only `a`.
#[tokio::test]
async fn from_lists_deny_wins_and_allow_is_fail_closed() {
    let mw = Arc::new(ContextualToolSelectionMiddleware::from_lists(
        Some(["a", "b"]),
        ["b"],
    ));
    let exposed = exposed_tools_at_depth(mw, 0).await;
    assert_eq!(exposed, vec!["a".to_string()]);
}

/// A context-aware predicate hides the `privileged` tool at sub-agent depth
/// (>0) but exposes it at the top level (depth 0). The exposed toolsets differ
/// across the two runs.
#[tokio::test]
async fn contextual_predicate_varies_exposure_by_depth() {
    fn build() -> Arc<ContextualToolSelectionMiddleware> {
        Arc::new(ContextualToolSelectionMiddleware::new(Arc::new(
            |schema: &ToolSchema, sel: &ToolSelectionContext| {
                schema.name != "privileged" || sel.depth == 0
            },
        )))
    }

    let deep = exposed_tools_at_depth(build(), 2).await;
    let top = exposed_tools_at_depth(build(), 0).await;

    assert!(
        !deep.contains(&"privileged".to_string()),
        "privileged is hidden at depth 2, got {deep:?}"
    );
    assert!(
        top.contains(&"privileged".to_string()),
        "privileged is shown at depth 0, got {top:?}"
    );
    assert_ne!(deep, top, "the exposed toolset differs across depths");
    // Non-privileged tools remain visible at both depths.
    assert!(deep.contains(&"safe".to_string()));
    assert!(top.contains(&"safe".to_string()));
}

/// `inheriting` narrows (never widens) a child's toolset relative to its
/// parent, and the narrowing is audited end-to-end via an
/// [`AgentEvent::ToolsFiltered`] event on a real harness run.
///
/// Parent allows `{a,b,c}` and denies `{c}`; the child tries to allow
/// `{b,c,d}`. The effective allow-list is the intersection `{b,c}`, then the
/// parent's deny of `c` is layered back on, so only `b` survives — the child
/// cannot re-admit `a` (never parent-allowed), `c` (parent-denied), or `d`
/// (never parent-allowed).
#[tokio::test]
async fn inheriting_narrows_child_and_emits_tools_filtered_event() {
    let scripted = Arc::new(ScriptedModel::new(vec![text_response("done")]));

    let mut harness: AgentHarness<()> = AgentHarness::new();
    harness.register_model("mock", scripted.clone());
    harness.register_tool(Arc::new(FakeTool::returning("a", "ok")));
    harness.register_tool(Arc::new(FakeTool::returning("b", "ok")));
    harness.register_tool(Arc::new(FakeTool::returning("c", "ok")));
    harness.register_tool(Arc::new(FakeTool::returning("d", "ok")));
    harness.push_middleware(Arc::new(ContextualToolSelectionMiddleware::inheriting(
        Some(["a", "b", "c"]),
        ["c"],
        Some(["b", "c", "d"]),
        Vec::<String>::new(),
    )));

    let recorder = EventRecorder::new();
    let ctx = RunContext::new(RunConfig::new("inherit"), ()).with_events(recorder.sink());
    harness
        .invoke_in_context(&(), ctx, vec![Message::user("go")])
        .await
        .expect("the run completes with a single scripted text response");

    // The model saw only the intersection minus the parent's deny: `b`.
    let requests = scripted.requests();
    assert_eq!(requests.len(), 1, "exactly one model call was recorded");
    let exposed: Vec<&str> = requests[0].tools.iter().map(|t| t.name.as_str()).collect();
    assert_eq!(
        exposed,
        vec!["b"],
        "inheriting must narrow the child to `b`"
    );

    // The narrowing decision is auditable: exactly one ToolsFiltered event that
    // leaves one tool exposed and withholds the other three in original order.
    let filtered = recorder.events().into_iter().find_map(|e| match e {
        AgentEvent::ToolsFiltered {
            excluded,
            remaining,
            ..
        } => Some((excluded, remaining)),
        _ => None,
    });
    let (excluded, remaining) = filtered.expect("a ToolsFiltered event should be emitted");
    assert_eq!(remaining, 1, "one tool stays exposed");
    assert_eq!(
        excluded,
        vec!["a".to_string(), "c".to_string(), "d".to_string()],
        "the withheld tools are reported in original order"
    );
    assert!(
        recorder.kinds().iter().any(|k| k == "tool.filtered"),
        "the exposure decision is journaled under the tool.filtered kind"
    );
}

// ---------------------------------------------------------------------------
// Part C — retired-model resolution gating
// ---------------------------------------------------------------------------

/// A minimal [`ChatModel`] that advertises a caller-supplied [`ModelProfile`],
/// so resolution can observe its lifecycle [`ModelStatus`].
struct ProfiledModel {
    profile: ModelProfile,
}

impl ProfiledModel {
    fn with_status(status: ModelStatus) -> Self {
        Self {
            profile: ModelProfile {
                status,
                ..ModelProfile::permissive()
            },
        }
    }
}

#[async_trait]
impl ChatModel<()> for ProfiledModel {
    fn profile(&self) -> Option<&ModelProfile> {
        Some(&self.profile)
    }

    async fn invoke(
        &self,
        _state: &(),
        _request: ModelRequest,
    ) -> tinyagents::Result<ModelResponse> {
        Ok(text_response("ok"))
    }
}

/// A model whose profile reports [`ModelStatus::Retired`] is skipped by
/// resolution even on an explicit override (it falls back to the live default),
/// and opting in via `allow_retired` re-admits it as the request override.
#[tokio::test]
async fn retired_override_falls_back_to_live_default_unless_allowed() {
    let mut registry: ModelRegistry<()> = ModelRegistry::new();
    // "live" registers first, so it is the registry default.
    registry
        .register(
            "live",
            Arc::new(ProfiledModel::with_status(ModelStatus::Stable)),
        )
        .register(
            "retired",
            Arc::new(ProfiledModel::with_status(ModelStatus::Retired)),
        );

    // A live model resolves via an explicit override.
    let live = registry
        .resolve(ModelSelection {
            requested: Some("live".into()),
            ..ModelSelection::default()
        })
        .expect("a live model resolves");
    assert_eq!(live.resolved.name, "live");
    assert_eq!(live.resolved.source, ModelResolutionSource::RequestOverride);

    // A retired override is skipped (fail closed) and resolution falls through to
    // the live registry default rather than selecting the retired model.
    let fell_back = registry
        .resolve(ModelSelection {
            requested: Some("retired".into()),
            ..ModelSelection::default()
        })
        .expect("resolution falls back to the live default");
    assert_eq!(
        fell_back.resolved.name, "live",
        "a retired override must not be selected; it falls back to the live default"
    );
    assert_eq!(
        fell_back.resolved.source,
        ModelResolutionSource::RegistryDefault
    );

    // `allow_retired = true` re-admits the retired model as the request override.
    let readmitted = registry
        .resolve(ModelSelection {
            requested: Some("retired".into()),
            allow_retired: true,
            ..ModelSelection::default()
        })
        .expect("allow_retired re-admits the retired model");
    assert_eq!(readmitted.resolved.name, "retired");
    assert_eq!(
        readmitted.resolved.source,
        ModelResolutionSource::RequestOverride
    );
}

/// When every candidate is retired, default resolution yields nothing;
/// `allow_retired` makes the retired default resolvable again.
#[tokio::test]
async fn all_retired_registry_resolves_to_none_until_allowed() {
    let mut registry: ModelRegistry<()> = ModelRegistry::new();
    registry.register(
        "only_retired",
        Arc::new(ProfiledModel::with_status(ModelStatus::Retired)),
    );

    assert!(
        registry.resolve(ModelSelection::default()).is_none(),
        "a registry with only a retired default resolves to None"
    );

    let allowed = registry
        .resolve(ModelSelection {
            allow_retired: true,
            ..ModelSelection::default()
        })
        .expect("allow_retired admits the retired default");
    assert_eq!(allowed.resolved.name, "only_retired");
}