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