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")]
28#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS), ts(export))]
29pub enum TaskCategory {
30 /// Exactly one tool call is expected.
31 SingleCall,
32 /// Multiple independent tool calls are expected in the same turn.
33 ParallelCall,
34 /// A multi-turn, stateful scenario requiring sequential tool calls
35 /// that build on prior tool results.
36 MultiTurn,
37 /// No tool call is expected at all — tests whether the model correctly
38 /// abstains instead of calling a tool it doesn't need.
39 Irrelevance,
40 /// Same evaluation as the other categories, but `user_prompt` is sent
41 /// after [`TuneTask::history`] has already been injected into the
42 /// conversation — tests whether a long prior session (thousands of
43 /// tokens of simulated dummy code/turns) causes the model to lose
44 /// attention and trigger the agent loop's `LoopDetector`/
45 /// `StagnationDetector`, or mis-call a tool it would otherwise get
46 /// right from a cold start.
47 LongContext,
48}
49
50/// One expected tool call within a task's [`ExpectedOutcome::ToolCalls`].
51///
52/// Matching is AST-style (BFCL-inspired), not a string diff: the recorded
53/// call's `name` must match exactly, and `required_args` must be a subset of
54/// the recorded arguments (extra arguments the model supplies are ignored).
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct ExpectedCall {
57 /// Expected tool name.
58 pub name: String,
59 /// Required argument key/value pairs. The recorded call's arguments must
60 /// contain each of these keys with matching values; additional
61 /// arguments in the recorded call are ignored.
62 #[serde(default)]
63 pub required_args: serde_json::Map<String, serde_json::Value>,
64 /// When `true`, this call must occur in the given position relative to
65 /// other expected calls (order matters). When `false`, expected calls
66 /// may be matched against recorded calls in any order.
67 ///
68 /// Ordering is checked across tool-call *batches*, not across the flat
69 /// call log. Two calls the model emitted in one parallel batch were not
70 /// ordered by the model at all, so demanding an order between them scores
71 /// a scheduler's arbitrary completion sequence rather than the model.
72 #[serde(default)]
73 pub ordered: bool,
74 /// When `true`, this call's arguments depend on the **result** of the call
75 /// before it, so it may only be credited in a strictly later batch.
76 ///
77 /// [`Self::ordered`] alone cannot express this. A model that emits
78 /// `file_exists` and `delete_file` in a single parallel batch satisfies
79 /// every ordering constraint available — the calls are simultaneous, so no
80 /// order is violated — while demonstrating none of the competency the task
81 /// exists to test: it deleted the file without ever seeing whether it was
82 /// there. Marking the second call here makes the two-turn structure a
83 /// requirement rather than an accident of how the model chose to batch.
84 ///
85 /// Deliberately **not** set on every multi-turn task. Creating a file and
86 /// then appending to a path you already know needs no intervening result,
87 /// so a model that does both at once is being more efficient rather than
88 /// skipping a step, and should keep the credit.
89 #[serde(default)]
90 pub depends_on_result: bool,
91}
92
93/// What a task expects the agent loop to do.
94#[derive(Debug, Clone, Serialize, Deserialize)]
95#[serde(tag = "kind", rename_all = "snake_case")]
96pub enum ExpectedOutcome {
97 /// One or more tool calls are expected, matched AST-style.
98 ToolCalls {
99 /// The expected calls (order-checked only when a call sets `ordered: true`).
100 calls: Vec<ExpectedCall>,
101 },
102 /// No tool call is expected (irrelevance-detection task).
103 NoToolCall,
104}
105
106/// A single scripted agentic scenario evaluated during a tune run.
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct TuneTask {
109 /// Stable identifier for this task (used in results and diagnostics).
110 pub id: String,
111 /// BFCL-style category this task belongs to.
112 pub category: TaskCategory,
113 /// Optional system prompt for this task.
114 #[serde(default)]
115 pub system_prompt: Option<String>,
116 /// Simulated prior conversation turns injected before `user_prompt`,
117 /// used by [`TaskCategory::LongContext`] tasks to test whether context
118 /// degradation over a long session induces loop/stagnation behavior
119 /// that would not occur from a cold start. `None`/empty for every other
120 /// category.
121 #[serde(default)]
122 pub history: Option<Vec<AgentMessage>>,
123 /// User prompt sent to the agent loop (after `history`, if present).
124 pub user_prompt: String,
125 /// Tools advertised to the model for this task (OpenAI-format schema).
126 pub tools: Vec<ToolDefinition>,
127 /// Expected outcome used to score the recorded tool calls.
128 pub expected: ExpectedOutcome,
129}
130
131/// The set of tasks a tune run evaluates each candidate against.
132///
133/// `Custom` carries the exact same JSON shape whether it originates from a
134/// CLI `--task-suite path.json` file or a GUI file upload parsed
135/// client-side and posted as part of the run request — there is a single
136/// shared schema, not two divergent ingestion paths.
137#[derive(Debug, Clone, Serialize, Deserialize)]
138#[serde(tag = "source", rename_all = "snake_case")]
139pub enum TaskSuite {
140 /// The built-in default suite (see `assets/tune_default_suite.json`).
141 Default,
142 /// A user-authored suite.
143 Custom { tasks: Vec<TuneTask> },
144}
145
146impl TaskSuite {
147 /// Embedded JSON for the built-in default suite (BFCL-style: single-call,
148 /// parallel-call, multi-turn, and irrelevance-detection scenarios, plus
149 /// a long-context endurance scenario).
150 const DEFAULT_SUITE_JSON: &'static str =
151 include_str!("../../../../assets/tune_default_suite.json");
152
153 /// Resolve this suite into its concrete list of tasks.
154 ///
155 /// # Errors
156 ///
157 /// Returns an error only for [`TaskSuite::Default`], and only if the
158 /// embedded JSON asset is malformed — that would indicate a build-time
159 /// bug in gglib itself, never a user input error. [`TaskSuite::Custom`]
160 /// never errors here (its tasks were already deserialized when the
161 /// `TaskSuite` value itself was parsed).
162 pub fn resolve(&self) -> Result<Vec<TuneTask>, serde_json::Error> {
163 match self {
164 Self::Default => serde_json::from_str(Self::DEFAULT_SUITE_JSON),
165 Self::Custom { tasks } => Ok(tasks.clone()),
166 }
167 }
168}
169
170#[cfg(test)]
171#[path = "task_tests.rs"]
172mod task_tests;