Skip to main content

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}