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")]
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;