Skip to main content

gglib_core/domain/benchmark/tune/
task.rs

1//! Agentic tool-calling task schema for tune runs.
2//!
3//! A [`TuneTask`] is a single scripted scenario: a prompt, a set of tools
4//! advertised to the model, and an expected outcome. Tasks are evaluated
5//! through the real agent loop (not a toy harness) so the recorded tool
6//! calls reflect exactly what the model would do in production.
7//!
8//! # Categories
9//!
10//! Modeled after the Berkeley Function Calling Leaderboard (BFCL)
11//! methodology: single-call and parallel-call correctness, multi-turn
12//! (stateful) tool use, and — importantly for avoiding loops — irrelevance
13//! detection (can the model correctly abstain from calling a tool when none
14//! applies). A fifth category, [`TaskCategory::LongContext`], goes beyond
15//! BFCL: it pre-fills the conversation with a long simulated history before
16//! `user_prompt`, testing whether context degradation over a long session
17//! (attention fixating on stale context) causes the model to loop or
18//! stagnate on a task it would otherwise handle cleanly from a cold start.
19
20use serde::{Deserialize, Serialize};
21
22use crate::domain::agent::{AgentMessage, ToolDefinition};
23
24/// Category of an agentic tool-calling scenario, following the BFCL split
25/// (plus [`LongContext`](Self::LongContext), which is gglib-specific).
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum TaskCategory {
29    /// Exactly one tool call is expected.
30    SingleCall,
31    /// Multiple independent tool calls are expected in the same turn.
32    ParallelCall,
33    /// A multi-turn, stateful scenario requiring sequential tool calls
34    /// that build on prior tool results.
35    MultiTurn,
36    /// No tool call is expected at all — tests whether the model correctly
37    /// abstains instead of calling a tool it doesn't need.
38    Irrelevance,
39    /// Same evaluation as the other categories, but `user_prompt` is sent
40    /// after [`TuneTask::history`] has already been injected into the
41    /// conversation — tests whether a long prior session (thousands of
42    /// tokens of simulated dummy code/turns) causes the model to lose
43    /// attention and trigger the agent loop's `LoopDetector`/
44    /// `StagnationDetector`, or mis-call a tool it would otherwise get
45    /// right from a cold start.
46    LongContext,
47}
48
49/// One expected tool call within a task's [`ExpectedOutcome::ToolCalls`].
50///
51/// Matching is AST-style (BFCL-inspired), not a string diff: the recorded
52/// call's `name` must match exactly, and `required_args` must be a subset of
53/// the recorded arguments (extra arguments the model supplies are ignored).
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct ExpectedCall {
56    /// Expected tool name.
57    pub name: String,
58    /// Required argument key/value pairs. The recorded call's arguments must
59    /// contain each of these keys with matching values; additional
60    /// arguments in the recorded call are ignored.
61    #[serde(default)]
62    pub required_args: serde_json::Map<String, serde_json::Value>,
63    /// When `true`, this call must occur in the given position relative to
64    /// other expected calls (order matters). When `false`, expected calls
65    /// may be matched against recorded calls in any order.
66    #[serde(default)]
67    pub ordered: bool,
68}
69
70/// What a task expects the agent loop to do.
71#[derive(Debug, Clone, Serialize, Deserialize)]
72#[serde(tag = "kind", rename_all = "snake_case")]
73pub enum ExpectedOutcome {
74    /// One or more tool calls are expected, matched AST-style.
75    ToolCalls {
76        /// The expected calls (order-checked only when a call sets `ordered: true`).
77        calls: Vec<ExpectedCall>,
78    },
79    /// No tool call is expected (irrelevance-detection task).
80    NoToolCall,
81}
82
83/// A single scripted agentic scenario evaluated during a tune run.
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct TuneTask {
86    /// Stable identifier for this task (used in results and diagnostics).
87    pub id: String,
88    /// BFCL-style category this task belongs to.
89    pub category: TaskCategory,
90    /// Optional system prompt for this task.
91    #[serde(default)]
92    pub system_prompt: Option<String>,
93    /// Simulated prior conversation turns injected before `user_prompt`,
94    /// used by [`TaskCategory::LongContext`] tasks to test whether context
95    /// degradation over a long session induces loop/stagnation behavior
96    /// that would not occur from a cold start. `None`/empty for every other
97    /// category.
98    #[serde(default)]
99    pub history: Option<Vec<AgentMessage>>,
100    /// User prompt sent to the agent loop (after `history`, if present).
101    pub user_prompt: String,
102    /// Tools advertised to the model for this task (OpenAI-format schema).
103    pub tools: Vec<ToolDefinition>,
104    /// Expected outcome used to score the recorded tool calls.
105    pub expected: ExpectedOutcome,
106}
107
108/// The set of tasks a tune run evaluates each candidate against.
109///
110/// `Custom` carries the exact same JSON shape whether it originates from a
111/// CLI `--task-suite path.json` file or a GUI file upload parsed
112/// client-side and posted as part of the run request — there is a single
113/// shared schema, not two divergent ingestion paths.
114#[derive(Debug, Clone, Serialize, Deserialize)]
115#[serde(tag = "source", rename_all = "snake_case")]
116pub enum TaskSuite {
117    /// The built-in default suite (see `assets/tune_default_suite.json`).
118    Default,
119    /// A user-authored suite.
120    Custom { tasks: Vec<TuneTask> },
121}
122
123impl TaskSuite {
124    /// Embedded JSON for the built-in default suite (BFCL-style: single-call,
125    /// parallel-call, multi-turn, and irrelevance-detection scenarios, plus
126    /// a long-context endurance scenario).
127    const DEFAULT_SUITE_JSON: &'static str =
128        include_str!("../../../../assets/tune_default_suite.json");
129
130    /// Resolve this suite into its concrete list of tasks.
131    ///
132    /// # Errors
133    ///
134    /// Returns an error only for [`TaskSuite::Default`], and only if the
135    /// embedded JSON asset is malformed — that would indicate a build-time
136    /// bug in gglib itself, never a user input error. [`TaskSuite::Custom`]
137    /// never errors here (its tasks were already deserialized when the
138    /// `TaskSuite` value itself was parsed).
139    pub fn resolve(&self) -> Result<Vec<TuneTask>, serde_json::Error> {
140        match self {
141            Self::Default => serde_json::from_str(Self::DEFAULT_SUITE_JSON),
142            Self::Custom { tasks } => Ok(tasks.clone()),
143        }
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    /// `ExpectedOutcome` is `#[serde(tag = "kind")]` (internally tagged), which
152    /// only supports newtype variants whose inner value serializes as a JSON
153    /// object/map. `ToolCalls` must therefore stay a *struct* variant
154    /// (`{ calls: Vec<..> }`), never a bare `ToolCalls(Vec<..>)` newtype —
155    /// the latter fails at serialization time with "cannot serialize tagged
156    /// newtype variant ... containing a sequence".
157    #[test]
158    fn expected_outcome_tool_calls_round_trips() {
159        let outcome = ExpectedOutcome::ToolCalls {
160            calls: vec![ExpectedCall {
161                name: "get_weather".to_string(),
162                required_args: serde_json::Map::new(),
163                ordered: false,
164            }],
165        };
166        let json = serde_json::to_string(&outcome).expect("serializes");
167        let round_tripped: ExpectedOutcome = serde_json::from_str(&json).expect("deserializes");
168        assert!(matches!(round_tripped, ExpectedOutcome::ToolCalls { .. }));
169    }
170
171    #[test]
172    fn expected_outcome_no_tool_call_round_trips() {
173        let json = serde_json::to_string(&ExpectedOutcome::NoToolCall).expect("serializes");
174        let round_tripped: ExpectedOutcome = serde_json::from_str(&json).expect("deserializes");
175        assert!(matches!(round_tripped, ExpectedOutcome::NoToolCall));
176    }
177
178    #[test]
179    fn task_suite_custom_round_trips() {
180        let suite = TaskSuite::Custom {
181            tasks: vec![TuneTask {
182                id: "single_call_example".to_string(),
183                category: TaskCategory::SingleCall,
184                system_prompt: None,
185                history: None,
186                user_prompt: "What's the weather in Boston?".to_string(),
187                tools: vec![],
188                expected: ExpectedOutcome::NoToolCall,
189            }],
190        };
191        let json = serde_json::to_string(&suite).expect("serializes");
192        let round_tripped: TaskSuite = serde_json::from_str(&json).expect("deserializes");
193        assert!(matches!(round_tripped, TaskSuite::Custom { .. }));
194    }
195
196    /// Guards the embedded default suite asset: it must always parse, and
197    /// must cover all five categories so the pre-screen round (which picks
198    /// one `SingleCall` + one `Irrelevance` task) always has candidates to
199    /// draw from, and the endurance scenario is never silently dropped.
200    #[test]
201    fn default_suite_parses_and_covers_all_categories() {
202        let tasks = TaskSuite::Default.resolve().expect("embedded suite parses");
203        assert!(!tasks.is_empty(), "default suite must not be empty");
204
205        for category in [
206            TaskCategory::SingleCall,
207            TaskCategory::ParallelCall,
208            TaskCategory::MultiTurn,
209            TaskCategory::Irrelevance,
210            TaskCategory::LongContext,
211        ] {
212            assert!(
213                tasks.iter().any(|t| t.category == category),
214                "default suite missing a task in category {category:?}"
215            );
216        }
217
218        // Task IDs must be unique — the tune service keys results by ID.
219        let mut ids: Vec<&str> = tasks.iter().map(|t| t.id.as_str()).collect();
220        ids.sort_unstable();
221        ids.dedup();
222        assert_eq!(
223            ids.len(),
224            tasks.len(),
225            "default suite has duplicate task IDs"
226        );
227    }
228
229    /// The long-context task must actually carry a non-trivial pre-filled
230    /// history — otherwise it's indistinguishable from a cold-start task and
231    /// defeats the purpose of the category.
232    #[test]
233    fn long_context_task_has_substantial_history() {
234        let tasks = TaskSuite::Default.resolve().expect("embedded suite parses");
235        let long_context_tasks: Vec<_> = tasks
236            .iter()
237            .filter(|t| t.category == TaskCategory::LongContext)
238            .collect();
239        assert!(
240            !long_context_tasks.is_empty(),
241            "expected at least one long_context task"
242        );
243        for task in long_context_tasks {
244            let history = task
245                .history
246                .as_ref()
247                .expect("long_context task must set history");
248            assert!(
249                history.len() >= 8,
250                "long_context task '{}' history too short ({} messages) to \
251                 meaningfully simulate context degradation",
252                task.id,
253                history.len()
254            );
255        }
256    }
257}