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}