Skip to main content

vv_agent/types/
tasks.rs

1use std::collections::BTreeMap;
2
3use serde::{de::Error as _, Deserialize, Deserializer, Serialize};
4use serde_json::Value;
5
6use crate::model_settings::ModelSettings;
7use crate::tools::common::trim_portable_whitespace;
8use crate::tools::{ToolPolicy, ToolSideEffect};
9
10use super::{
11    json_value_from_serializable, AgentStatus, CompletionReason, Message, Metadata, NoToolPolicy,
12};
13
14pub const INVALID_SUB_AGENT_MODEL_CODE: &str = "invalid_sub_agent_model";
15pub const INVALID_SUB_AGENT_MODEL_MESSAGE: &str = "sub-agent model cannot be empty";
16pub const INVALID_SUB_AGENT_SYSTEM_PROMPT_CODE: &str = "invalid_sub_agent_system_prompt";
17pub const INVALID_SUB_AGENT_SYSTEM_PROMPT_MESSAGE: &str =
18    "sub-agent system_prompt cannot be empty when provided";
19
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct SubAgentConfigValidationError {
22    code: &'static str,
23    message: &'static str,
24}
25
26impl SubAgentConfigValidationError {
27    fn new(code: &'static str, message: &'static str) -> Self {
28        Self { code, message }
29    }
30
31    pub fn code(&self) -> &'static str {
32        self.code
33    }
34
35    pub fn message(&self) -> &'static str {
36        self.message
37    }
38}
39
40impl std::fmt::Display for SubAgentConfigValidationError {
41    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        formatter.write_str(self.message)
43    }
44}
45
46impl std::error::Error for SubAgentConfigValidationError {}
47
48#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
49pub struct SubAgentConfig {
50    pub model: String,
51    pub description: String,
52    pub backend: Option<String>,
53    pub system_prompt: Option<String>,
54    pub max_cycles: u32,
55    pub session_memory_enabled: bool,
56    pub exclude_tools: Vec<String>,
57    pub metadata: Metadata,
58    pub denied_side_effects: Vec<ToolSideEffect>,
59    pub denied_capability_tags: Vec<String>,
60    pub deny_terminal_tools: bool,
61    pub denied_cost_dimensions: Vec<String>,
62}
63
64#[derive(Deserialize)]
65#[serde(deny_unknown_fields)]
66struct SubAgentConfigWire {
67    model: String,
68    #[serde(default)]
69    description: String,
70    #[serde(default)]
71    backend: Option<String>,
72    #[serde(default)]
73    system_prompt: Option<String>,
74    #[serde(default = "default_sub_agent_max_cycles")]
75    max_cycles: u32,
76    #[serde(default)]
77    session_memory_enabled: bool,
78    #[serde(default)]
79    exclude_tools: Vec<String>,
80    #[serde(default)]
81    metadata: Metadata,
82    #[serde(default)]
83    denied_side_effects: Vec<ToolSideEffect>,
84    #[serde(default)]
85    denied_capability_tags: Vec<String>,
86    #[serde(default)]
87    deny_terminal_tools: bool,
88    #[serde(default)]
89    denied_cost_dimensions: Vec<String>,
90}
91
92const fn default_sub_agent_max_cycles() -> u32 {
93    8
94}
95
96impl<'de> Deserialize<'de> for SubAgentConfig {
97    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
98    where
99        D: Deserializer<'de>,
100    {
101        let value = Value::deserialize(deserializer)?;
102        if !value.is_object() {
103            return Err(D::Error::custom("SubAgentConfig payload must be an object"));
104        }
105        let wire = serde_json::from_value::<SubAgentConfigWire>(value).map_err(D::Error::custom)?;
106        let mut config = Self {
107            model: trim_portable_whitespace(&wire.model).to_string(),
108            description: wire.description,
109            backend: wire.backend,
110            system_prompt: wire.system_prompt,
111            max_cycles: wire.max_cycles,
112            session_memory_enabled: wire.session_memory_enabled,
113            exclude_tools: wire.exclude_tools,
114            metadata: wire.metadata,
115            denied_side_effects: wire.denied_side_effects,
116            denied_capability_tags: wire.denied_capability_tags,
117            deny_terminal_tools: wire.deny_terminal_tools,
118            denied_cost_dimensions: wire.denied_cost_dimensions,
119        };
120        config
121            .normalize_policy_denials()
122            .map_err(D::Error::custom)?;
123        config.validate().map_err(D::Error::custom)?;
124        Ok(config)
125    }
126}
127
128impl SubAgentConfig {
129    pub fn new(model: impl Into<String>, description: impl Into<String>) -> Self {
130        let model = model.into();
131        Self {
132            model: trim_portable_whitespace(&model).to_string(),
133            description: description.into(),
134            backend: None,
135            system_prompt: None,
136            max_cycles: 8,
137            session_memory_enabled: false,
138            exclude_tools: Vec::new(),
139            metadata: Metadata::new(),
140            denied_side_effects: Vec::new(),
141            denied_capability_tags: Vec::new(),
142            deny_terminal_tools: false,
143            denied_cost_dimensions: Vec::new(),
144        }
145    }
146
147    pub fn validate(&self) -> Result<(), SubAgentConfigValidationError> {
148        if trim_portable_whitespace(&self.model).is_empty() {
149            return Err(SubAgentConfigValidationError::new(
150                INVALID_SUB_AGENT_MODEL_CODE,
151                INVALID_SUB_AGENT_MODEL_MESSAGE,
152            ));
153        }
154        if self
155            .system_prompt
156            .as_deref()
157            .is_some_and(|prompt| trim_portable_whitespace(prompt).is_empty())
158        {
159            return Err(SubAgentConfigValidationError::new(
160                INVALID_SUB_AGENT_SYSTEM_PROMPT_CODE,
161                INVALID_SUB_AGENT_SYSTEM_PROMPT_MESSAGE,
162            ));
163        }
164        self.declared_tool_policy().normalized().map_err(|_| {
165            SubAgentConfigValidationError::new(
166                "invalid_sub_agent_tool_policy",
167                "sub-agent tool policy is invalid",
168            )
169        })?;
170        Ok(())
171    }
172
173    pub fn declared_tool_policy(&self) -> ToolPolicy {
174        ToolPolicy {
175            denied_side_effects: self.denied_side_effects.clone(),
176            denied_capability_tags: self.denied_capability_tags.clone(),
177            deny_terminal_tools: self.deny_terminal_tools,
178            denied_cost_dimensions: self.denied_cost_dimensions.clone(),
179            ..ToolPolicy::default()
180        }
181    }
182
183    fn normalize_policy_denials(&mut self) -> Result<(), crate::tools::ToolMetadataError> {
184        let policy = self.declared_tool_policy().normalized()?;
185        self.denied_side_effects = policy.denied_side_effects;
186        self.denied_capability_tags = policy.denied_capability_tags;
187        self.deny_terminal_tools = policy.deny_terminal_tools;
188        self.denied_cost_dimensions = policy.denied_cost_dimensions;
189        Ok(())
190    }
191}
192
193#[derive(Debug, Clone, PartialEq, Serialize)]
194pub struct AgentTask {
195    pub task_id: String,
196    pub model: String,
197    pub system_prompt: String,
198    pub user_prompt: String,
199    pub max_cycles: u32,
200    pub memory_compact_threshold: u64,
201    pub memory_threshold_percentage: u8,
202    pub no_tool_policy: NoToolPolicy,
203    pub allow_interruption: bool,
204    pub use_workspace: bool,
205    pub sub_agents: BTreeMap<String, SubAgentConfig>,
206    pub agent_type: Option<String>,
207    pub native_multimodal: bool,
208    pub extra_tool_names: Vec<String>,
209    pub exclude_tools: Vec<String>,
210    pub initial_messages: Vec<Message>,
211    pub initial_shared_state: Metadata,
212    pub model_settings: Option<ModelSettings>,
213    pub metadata: Metadata,
214}
215
216#[derive(Deserialize)]
217#[serde(deny_unknown_fields)]
218struct AgentTaskWire {
219    task_id: String,
220    model: String,
221    system_prompt: String,
222    user_prompt: String,
223    #[serde(default = "default_agent_task_max_cycles")]
224    max_cycles: u32,
225    #[serde(default = "default_memory_compact_threshold")]
226    memory_compact_threshold: u64,
227    #[serde(default = "default_memory_threshold_percentage")]
228    memory_threshold_percentage: u8,
229    #[serde(default)]
230    no_tool_policy: NoToolPolicy,
231    #[serde(default = "default_true")]
232    allow_interruption: bool,
233    #[serde(default = "default_true")]
234    use_workspace: bool,
235    #[serde(default)]
236    sub_agents: BTreeMap<String, SubAgentConfig>,
237    #[serde(default)]
238    agent_type: Option<String>,
239    #[serde(default)]
240    native_multimodal: bool,
241    #[serde(default)]
242    extra_tool_names: Vec<String>,
243    #[serde(default)]
244    exclude_tools: Vec<String>,
245    #[serde(default, deserialize_with = "deserialize_agent_task_messages")]
246    initial_messages: Vec<Message>,
247    #[serde(default)]
248    initial_shared_state: Metadata,
249    #[serde(default, deserialize_with = "deserialize_agent_task_model_settings")]
250    model_settings: Option<ModelSettings>,
251    #[serde(default)]
252    metadata: Metadata,
253}
254
255impl<'de> Deserialize<'de> for AgentTask {
256    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
257    where
258        D: Deserializer<'de>,
259    {
260        let value = Value::deserialize(deserializer)?;
261        if !value.is_object() {
262            return Err(D::Error::custom("AgentTask payload must be an object"));
263        }
264        let wire = serde_json::from_value::<AgentTaskWire>(value).map_err(D::Error::custom)?;
265        Ok(Self {
266            task_id: wire.task_id,
267            model: wire.model,
268            system_prompt: wire.system_prompt,
269            user_prompt: wire.user_prompt,
270            max_cycles: wire.max_cycles,
271            memory_compact_threshold: wire.memory_compact_threshold,
272            memory_threshold_percentage: wire.memory_threshold_percentage,
273            no_tool_policy: wire.no_tool_policy,
274            allow_interruption: wire.allow_interruption,
275            use_workspace: wire.use_workspace,
276            sub_agents: wire.sub_agents,
277            agent_type: wire.agent_type,
278            native_multimodal: wire.native_multimodal,
279            extra_tool_names: wire.extra_tool_names,
280            exclude_tools: wire.exclude_tools,
281            initial_messages: wire.initial_messages,
282            initial_shared_state: wire.initial_shared_state,
283            model_settings: wire.model_settings,
284            metadata: wire.metadata,
285        })
286    }
287}
288
289const fn default_agent_task_max_cycles() -> u32 {
290    8
291}
292
293const fn default_memory_compact_threshold() -> u64 {
294    250_000
295}
296
297const fn default_memory_threshold_percentage() -> u8 {
298    90
299}
300
301const fn default_true() -> bool {
302    true
303}
304
305fn deserialize_agent_task_messages<'de, D>(deserializer: D) -> Result<Vec<Message>, D::Error>
306where
307    D: Deserializer<'de>,
308{
309    Vec::<Value>::deserialize(deserializer)?
310        .into_iter()
311        .enumerate()
312        .map(|(index, value)| {
313            validate_agent_task_message(&value, index).map_err(D::Error::custom)?;
314            Message::from_dict(&value).map_err(D::Error::custom)
315        })
316        .collect()
317}
318
319fn deserialize_agent_task_model_settings<'de, D>(
320    deserializer: D,
321) -> Result<Option<ModelSettings>, D::Error>
322where
323    D: Deserializer<'de>,
324{
325    let value = Option::<Value>::deserialize(deserializer)?;
326    value
327        .map(|value| {
328            if !value.is_object() {
329                return Err(D::Error::custom(
330                    "AgentTask field 'model_settings' must be an object or null",
331                ));
332            }
333            serde_json::from_value(value).map_err(D::Error::custom)
334        })
335        .transpose()
336}
337
338fn validate_agent_task_message(value: &Value, index: usize) -> Result<(), String> {
339    let object = value
340        .as_object()
341        .ok_or_else(|| format!("AgentTask initial_messages[{index}] must be an object"))?;
342    let role = object
343        .get("role")
344        .and_then(Value::as_str)
345        .ok_or_else(|| format!("AgentTask initial_messages[{index}].role must be a string"))?;
346    if !matches!(role, "system" | "user" | "assistant" | "tool") {
347        return Err(format!(
348            "unknown AgentTask initial_messages[{index}].role: {role}"
349        ));
350    }
351    if object
352        .get("content")
353        .is_some_and(|value| !value.is_string())
354    {
355        return Err(format!(
356            "AgentTask initial_messages[{index}].content must be a string"
357        ));
358    }
359    for field_name in ["name", "tool_call_id", "reasoning_content", "image_url"] {
360        if object
361            .get(field_name)
362            .is_some_and(|value| !value.is_null() && !value.is_string())
363        {
364            return Err(format!(
365                "AgentTask initial_messages[{index}].{field_name} must be a string or null"
366            ));
367        }
368    }
369    if object.get("tool_calls").is_some_and(|value| {
370        !value
371            .as_array()
372            .is_some_and(|items| items.iter().all(Value::is_object))
373    }) {
374        return Err(format!(
375            "AgentTask initial_messages[{index}].tool_calls must be an array of objects"
376        ));
377    }
378    if object
379        .get("metadata")
380        .is_some_and(|value| !value.is_object())
381    {
382        return Err(format!(
383            "AgentTask initial_messages[{index}].metadata must be an object"
384        ));
385    }
386    Ok(())
387}
388
389impl AgentTask {
390    pub fn new(
391        task_id: impl Into<String>,
392        model: impl Into<String>,
393        system_prompt: impl Into<String>,
394        user_prompt: impl Into<String>,
395    ) -> Self {
396        Self {
397            task_id: task_id.into(),
398            model: model.into(),
399            system_prompt: system_prompt.into(),
400            user_prompt: user_prompt.into(),
401            max_cycles: 8,
402            memory_compact_threshold: default_memory_compact_threshold(),
403            memory_threshold_percentage: 90,
404            no_tool_policy: NoToolPolicy::Continue,
405            allow_interruption: true,
406            use_workspace: true,
407            sub_agents: BTreeMap::new(),
408            agent_type: None,
409            native_multimodal: false,
410            extra_tool_names: Vec::new(),
411            exclude_tools: Vec::new(),
412            initial_messages: Vec::new(),
413            initial_shared_state: Metadata::new(),
414            model_settings: None,
415            metadata: Metadata::new(),
416        }
417    }
418
419    pub fn sub_agents_enabled(&self) -> bool {
420        !self.sub_agents.is_empty()
421    }
422}
423
424#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
425pub struct SubTaskRequest {
426    pub agent_name: String,
427    pub task_description: String,
428    pub output_requirements: String,
429    pub include_main_summary: bool,
430    pub exclude_files_pattern: Option<String>,
431    pub metadata: Metadata,
432}
433
434impl SubTaskRequest {
435    pub fn new(agent_name: impl Into<String>, task_description: impl Into<String>) -> Self {
436        Self {
437            agent_name: agent_name.into(),
438            task_description: task_description.into(),
439            output_requirements: String::new(),
440            include_main_summary: false,
441            exclude_files_pattern: None,
442            metadata: Metadata::new(),
443        }
444    }
445}
446
447#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
448pub struct SubTaskOutcome {
449    pub task_id: String,
450    pub agent_name: String,
451    pub status: AgentStatus,
452    pub session_id: Option<String>,
453    pub final_answer: Option<String>,
454    pub wait_reason: Option<String>,
455    pub error: Option<String>,
456    #[serde(default, skip_serializing_if = "Option::is_none")]
457    pub error_code: Option<String>,
458    #[serde(default, skip_serializing_if = "Option::is_none")]
459    pub completion_reason: Option<CompletionReason>,
460    #[serde(default, skip_serializing_if = "Option::is_none")]
461    pub completion_tool_name: Option<String>,
462    #[serde(default, skip_serializing_if = "Option::is_none")]
463    pub partial_output: Option<String>,
464    pub cycles: u32,
465    pub todo_list: Vec<Value>,
466    pub resolved: BTreeMap<String, String>,
467}
468
469impl Default for SubTaskOutcome {
470    fn default() -> Self {
471        Self {
472            task_id: String::new(),
473            agent_name: String::new(),
474            status: AgentStatus::Pending,
475            session_id: None,
476            final_answer: None,
477            wait_reason: None,
478            error: None,
479            error_code: None,
480            completion_reason: None,
481            completion_tool_name: None,
482            partial_output: None,
483            cycles: 0,
484            todo_list: Vec::new(),
485            resolved: BTreeMap::new(),
486        }
487    }
488}
489
490impl SubTaskOutcome {
491    pub fn to_dict(&self) -> Value {
492        self.to_value()
493    }
494
495    pub fn to_value(&self) -> Value {
496        json_value_from_serializable(self)
497    }
498}