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