Skip to main content

bamboo_engine/runtime/
task_context.rs

1//! TaskList context for Agent Loop integration
2//!
3//! This module provides TaskLoopContext which integrates TaskList
4//! as a first-class citizen in the Agent Loop, similar to Token Budget.
5
6use bamboo_domain::task::{TaskBlocker, TaskEvidence, TaskPhase, TaskPriority, TaskTransition};
7use bamboo_domain::TaskItemStatus;
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10use std::collections::HashSet;
11
12mod auto_status;
13mod conversion;
14mod prompt;
15mod tracking;
16
17/// TaskList context for Agent Loop
18///
19/// Acts as a first-class citizen in the agent loop, tracking
20/// task progress throughout the entire conversation lifecycle.
21#[derive(Debug, Clone)]
22pub struct TaskLoopContext {
23    /// Session ID
24    pub session_id: String,
25
26    /// Task items with execution tracking
27    pub items: Vec<TaskLoopItem>,
28
29    /// Currently active task item ID
30    pub active_item_id: Option<String>,
31
32    /// Current round number
33    pub current_round: u32,
34
35    /// Round cap for this run, `None` = unlimited. Mirrors
36    /// `RunBudgetConfig::max_rounds`.
37    pub max_rounds: Option<u32>,
38
39    /// Creation timestamp
40    pub created_at: DateTime<Utc>,
41
42    /// Last update timestamp
43    pub updated_at: DateTime<Utc>,
44
45    /// Version number for conflict detection
46    pub version: u64,
47
48    /// Set when the Task tool structurally rewrites the list, cleared once an
49    /// evaluation has been spawned for that change. This is the single signal that
50    /// gates async task evaluation to actual Task-tool writes, instead of firing
51    /// every round of tool activity (which bumps `version` without changing the plan).
52    pub task_list_dirty: bool,
53}
54
55/// Task item with execution tracking
56#[derive(Debug, Clone, Serialize, Deserialize, Default)]
57pub struct TaskLoopItem {
58    /// Item ID
59    pub id: String,
60
61    /// Item description
62    pub description: String,
63
64    /// Item status
65    pub status: TaskItemStatus,
66
67    /// IDs of other items this item depends on.
68    pub depends_on: Vec<String>,
69
70    /// Additional notes or context.
71    pub notes: String,
72
73    /// Present-progress phrasing for the active task.
74    pub active_form: Option<String>,
75
76    /// Optional parent task ID when this item is part of a larger task tree.
77    pub parent_id: Option<String>,
78
79    /// Phase of work for the task.
80    pub phase: TaskPhase,
81
82    /// Relative priority of the task.
83    pub priority: TaskPriority,
84
85    /// Explicit completion criteria for the task.
86    pub completion_criteria: Vec<String>,
87
88    /// Structured evidence gathered while working on the task.
89    pub evidence: Vec<TaskEvidence>,
90
91    /// Structured blocker information.
92    pub blockers: Vec<TaskBlocker>,
93
94    /// Transition history for this task item.
95    pub transitions: Vec<TaskTransition>,
96
97    /// Tool call history (tracks execution process)
98    pub tool_calls: Vec<ToolCallRecord>,
99
100    /// Round when item was started
101    pub started_at_round: Option<u32>,
102
103    /// Round when item was completed
104    pub completed_at_round: Option<u32>,
105}
106
107/// Record of a tool call execution
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct ToolCallRecord {
110    /// Round number
111    pub round: u32,
112
113    /// Tool name
114    pub tool_name: String,
115
116    /// Whether the call succeeded
117    pub success: bool,
118
119    /// Timestamp
120    pub timestamp: DateTime<Utc>,
121}
122
123impl TaskLoopContext {
124    /// Check if all items are completed
125    pub fn is_all_completed(&self) -> bool {
126        !self.items.is_empty()
127            && self
128                .items
129                .iter()
130                .all(|item| matches!(item.status, TaskItemStatus::Completed))
131    }
132
133    fn completed_item_ids(&self) -> HashSet<String> {
134        self.items
135            .iter()
136            .filter(|item| matches!(item.status, TaskItemStatus::Completed))
137            .map(|item| item.id.clone())
138            .collect()
139    }
140
141    fn unresolved_dependencies(&self, depends_on: &[String]) -> Vec<String> {
142        let completed = self.completed_item_ids();
143        depends_on
144            .iter()
145            .filter(|dependency| !completed.contains(*dependency))
146            .cloned()
147            .collect()
148    }
149
150    fn append_item_notes(item: &mut TaskLoopItem, note: &str) {
151        let note = note.trim();
152        if note.is_empty() {
153            return;
154        }
155        if !item.notes.is_empty() {
156            item.notes.push('\n');
157        }
158        item.notes.push_str(note);
159    }
160
161    fn transition_item(
162        item: &mut TaskLoopItem,
163        status: TaskItemStatus,
164        reason: Option<&str>,
165        round: Option<u32>,
166    ) -> bool {
167        let reason = reason.map(str::trim).filter(|value| !value.is_empty());
168        if item.status == status {
169            if let Some(reason) = reason {
170                Self::append_item_notes(item, reason);
171            }
172            return false;
173        }
174
175        let transition = TaskTransition {
176            from_status: item.status.clone(),
177            to_status: status.clone(),
178            reason: reason.map(ToOwned::to_owned),
179            round,
180            changed_at: Utc::now(),
181        };
182        item.status = status.clone();
183
184        if matches!(status, TaskItemStatus::InProgress) && item.started_at_round.is_none() {
185            item.started_at_round = round;
186        }
187        if matches!(status, TaskItemStatus::Completed) {
188            item.completed_at_round = round;
189        }
190        if let Some(reason) = transition.reason.as_deref() {
191            Self::append_item_notes(item, reason);
192        }
193
194        item.transitions.push(transition);
195        true
196    }
197
198    fn add_item_blocker(item: &mut TaskLoopItem, blocker: TaskBlocker) {
199        if blocker.summary.trim().is_empty() {
200            return;
201        }
202        if item.blockers.iter().any(|existing| {
203            existing.kind == blocker.kind
204                && existing.summary == blocker.summary
205                && existing.waiting_on == blocker.waiting_on
206        }) {
207            return;
208        }
209        item.blockers.push(blocker);
210    }
211
212    fn push_item_evidence(item: &mut TaskLoopItem, evidence: TaskEvidence) {
213        if evidence.summary.trim().is_empty() {
214            return;
215        }
216        item.evidence.push(evidence);
217    }
218}
219
220#[cfg(test)]
221mod tests;