gglib_core/request_pipeline/request_shape.rs
1//! What kind of turn a request body represents.
2//!
3//! The pipeline's shaping stages are otherwise built entirely from facts about
4//! the *model* ([`ModelContext`](super::ModelContext)). This module holds the
5//! one thing they need to know about the *request*: whether it is asking the
6//! model to emit a tool call.
7//!
8//! It lives here, rather than inside the stage that first needed it, because
9//! two stages read the same signal for different purposes —
10//! [`sampling`](super::sampling) caps the temperature from it, and
11//! [`constrain`](super::constrain) decides whether to originate a grammar —
12//! and a second copy of "does this request carry tools" would eventually
13//! answer differently from the first.
14
15use serde_json::Value;
16
17/// Whether the request carries a non-empty `tools` array.
18///
19/// # This identifies an agentic turn, not a tool-emission turn
20///
21/// The distinction matters and was learned the hard way. This answers *"could
22/// this turn produce a tool call?"*, never *"will it?"*. VS Code Copilot in
23/// agent mode sends the `tools` array on essentially every request —
24/// including prose, planning, summarising and thinking turns — so in the
25/// client this was built for, the answer is permanently yes.
26///
27/// Anything keyed on this therefore applies to a whole agentic session, not to
28/// the moment a call is emitted. Adjustments hanging off it must be safe for
29/// prose, because they will spend most of their time there.
30///
31/// # Why there is no better signal
32///
33/// Every candidate fails. `tool_choice` is `"auto"` on every turn. The last
34/// message's role does not predict what comes next. A history containing prior
35/// `tool_calls` only says "this is an agentic session", which is the thing
36/// already known. This is the same wall [`super::constrain`] documents for
37/// grammars: you cannot know before decoding whether the model will emit a
38/// call. The only true discriminator is mid-stream marker detection, which is
39/// a different and much larger piece of machinery.
40///
41/// # Deliberately more lenient than the grammar path
42///
43/// [`constrain`](super::constrain) needs the complete list of tool *names* to
44/// build a GBNF alternation, so it rejects a `tools` array in which any entry
45/// is missing `function.name`. That strictness is right for originating a
46/// grammar and wrong here: a request with one malformed tool entry is still an
47/// agentic turn.
48///
49/// A `tools` key that is present but not a non-empty array — `null`, `[]`, an
50/// object — reads as no tools. `tool_choice` is not consulted at all: it can
51/// appear without `tools` (nothing strips it), and on its own it does not make
52/// a turn agentic.
53#[must_use]
54pub fn carries_tools(body: &Value) -> bool {
55 body.get("tools")
56 .and_then(Value::as_array)
57 .is_some_and(|tools| !tools.is_empty())
58}
59
60#[cfg(test)]
61mod tests {
62 use super::*;
63 use serde_json::json;
64
65 #[test]
66 fn a_non_empty_tools_array_is_an_agentic_turn() {
67 let body = json!({"tools": [{"function": {"name": "read_file"}}]});
68 assert!(carries_tools(&body));
69 }
70
71 /// The leniency that separates this from `constrain`'s `tool_names`: a
72 /// malformed entry still means tools are in scope for this turn.
73 #[test]
74 fn a_tool_entry_without_a_name_is_still_an_agentic_turn() {
75 let body = json!({"tools": [{"function": {}}]});
76 assert!(carries_tools(&body));
77 }
78
79 #[test]
80 fn tool_choice_alone_is_not_an_agentic_turn() {
81 // `strip_unsupported_tools` removes `tools` but leaves a `tool_choice`
82 // behind when there were no tools to begin with, so this shape reaches
83 // the pipeline in practice.
84 let body = json!({"tool_choice": "required"});
85 assert!(!carries_tools(&body));
86 }
87
88 #[test]
89 fn an_empty_or_malformed_tools_key_is_not_an_agentic_turn() {
90 for body in [
91 json!({"tools": []}),
92 json!({"tools": null}),
93 json!({"tools": {"nested": true}}),
94 json!({}),
95 json!([1, 2, 3]),
96 ] {
97 assert!(!carries_tools(&body), "{body} should not read as tools");
98 }
99 }
100}