temporal-agent-rs 0.2.0-alpha.1

Durable AI agent execution on Temporal using AutoAgents traits.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
//! Pluggable history-compaction strategies for `AgentWorkflow`.
//!
//! The workflow consults a [`MemoryProvider`] before every reasoning turn to
//! decide whether to `continue_as_new` with a compacted state. The provider is
//! configured once per worker via
//! [`AgentWorkerBuilder::memory`](crate::AgentWorkerBuilder::memory); the
//! default is [`SlidingWindowMemory`], which reproduces the legacy hardcoded
//! behavior (drop everything older than the last 20 messages once history
//! exceeds 200).
//!
//! # Determinism contract
//!
//! Implementations run inside the deterministic workflow body. They MUST be:
//!
//! - **Pure**: no I/O, no clocks, no randomness — given the same `AgentState`,
//!   `should_compact` and `compact` must return the same result on every
//!   replay.
//! - **Synchronous**: the workflow body is sync-only. If you need network
//!   memory (vector store, etc.), that work belongs in an activity called by
//!   a future workflow-side `prepare` hook.
//! - **Stateless across calls**: all conversation state lives in `AgentState`
//!   and is restored from event history. Provider instances are configuration
//!   holders only.
//!
//! # Backward compatibility
//!
//! [`SlidingWindowMemory::default`] uses [`DEFAULT_COMPACT_THRESHOLD`] (200)
//! and [`DEFAULT_KEEP_RECENT`] (20), matching the constants that lived in
//! `src/workflow.rs` prior to this module. Workers that do not call
//! `.memory(...)` get this default and behave identically to earlier releases.

use crate::state::{AgentInput, AgentState, Role};

/// Default history length above which [`SlidingWindowMemory`] compacts.
pub const DEFAULT_COMPACT_THRESHOLD: usize = 200;

/// Default number of most-recent messages [`SlidingWindowMemory`] preserves
/// verbatim when compacting.
pub const DEFAULT_KEEP_RECENT: usize = 20;

/// Strategy for deciding when to compact agent history and how to do it.
///
/// See the module-level docs for the determinism contract every implementation
/// must uphold.
pub trait MemoryProvider: std::fmt::Debug + Send + Sync + 'static {
    /// Called every iteration of the agent loop. Return `true` to trigger a
    /// `continue_as_new` with the [`AgentInput`] returned by [`Self::compact`].
    ///
    /// MUST be pure and deterministic — it is replayed verbatim from history.
    fn should_compact(&self, state: &AgentState) -> bool;

    /// Produce the [`AgentInput`] that seeds the next workflow run after
    /// `continue_as_new`. Only called when [`Self::should_compact`] returned
    /// `true`.
    ///
    /// The returned value is serialized into workflow history; its byte shape
    /// IS the compaction. MUST be pure and deterministic.
    fn compact(&self, state: &AgentState) -> AgentInput;
}

/// FIFO sliding-window compaction. Drops everything older than the last
/// `keep_recent` messages once history exceeds `compact_threshold`, prepending
/// a synthetic text summary of the dropped turns to the system prompt.
///
/// This is the default provider and matches the pre-v0.2 hardcoded behavior.
#[derive(Debug, Clone)]
pub struct SlidingWindowMemory {
    compact_threshold: usize,
    keep_recent: usize,
}

impl SlidingWindowMemory {
    /// Construct with [`DEFAULT_COMPACT_THRESHOLD`] / [`DEFAULT_KEEP_RECENT`].
    #[must_use]
    pub fn new() -> Self {
        Self {
            compact_threshold: DEFAULT_COMPACT_THRESHOLD,
            keep_recent: DEFAULT_KEEP_RECENT,
        }
    }

    /// Override the history length at which compaction fires.
    ///
    /// Panics if `n <= keep_recent`, which would trigger compaction every
    /// iteration.
    #[must_use]
    pub fn with_compact_threshold(mut self, n: usize) -> Self {
        assert!(
            n > self.keep_recent,
            "compact_threshold ({}) must be greater than keep_recent ({})",
            n,
            self.keep_recent
        );
        self.compact_threshold = n;
        self
    }

    /// Override the number of messages preserved verbatim after compaction.
    ///
    /// Panics if `n >= compact_threshold`, which would trigger compaction
    /// every iteration.
    #[must_use]
    pub fn with_keep_recent(mut self, n: usize) -> Self {
        assert!(
            n < self.compact_threshold,
            "keep_recent ({}) must be less than compact_threshold ({})",
            n,
            self.compact_threshold
        );
        self.keep_recent = n;
        self
    }

    /// Inspect the current compaction trigger.
    #[must_use]
    pub fn compact_threshold(&self) -> usize {
        self.compact_threshold
    }

    /// Inspect the current keep-recent setting.
    #[must_use]
    pub fn keep_recent(&self) -> usize {
        self.keep_recent
    }
}

impl Default for SlidingWindowMemory {
    fn default() -> Self {
        Self::new()
    }
}

impl MemoryProvider for SlidingWindowMemory {
    fn should_compact(&self, state: &AgentState) -> bool {
        state.history.len() > self.compact_threshold
    }

    fn compact(&self, state: &AgentState) -> AgentInput {
        compact_sliding_window(state, self.keep_recent)
    }
}

/// Pure sliding-window compaction kernel.
///
/// Preserves the system prompt, summarizes everything before the last
/// `keep_recent` messages into a synthetic text block appended to the system
/// prompt, and threads through the original `max_turns` / `output_schema`.
///
/// Exposed for advanced users who want to call the kernel directly from a
/// custom [`MemoryProvider`] without re-implementing the summarization format.
pub fn compact_sliding_window(state: &AgentState, keep_recent: usize) -> AgentInput {
    let mut summary_lines = Vec::new();
    let total = state.history.len();
    let drop_until = total.saturating_sub(keep_recent);

    for msg in state.history.iter().take(drop_until) {
        let line = match msg.role {
            Role::System if summary_lines.is_empty() => continue,
            Role::User => format!("user: {}", truncate(&msg.content, 200)),
            Role::Assistant if !msg.tool_calls.is_empty() => {
                let names: Vec<&str> = msg.tool_calls.iter().map(|c| c.name.as_str()).collect();
                format!("assistant: called tools [{}]", names.join(", "))
            }
            Role::Assistant => format!("assistant: {}", truncate(&msg.content, 200)),
            Role::Tool => format!("tool: {}", truncate(&msg.content, 120)),
            Role::System => continue,
        };
        summary_lines.push(line);
    }

    let summary = if summary_lines.is_empty() {
        String::new()
    } else {
        format!(
            "\n\n[Prior conversation summary, {} messages dropped]\n{}",
            drop_until,
            summary_lines.join("\n")
        )
    };

    let recent_user = state
        .history
        .iter()
        .rev()
        .find(|m| m.role == Role::User)
        .map(|m| m.content.clone())
        .unwrap_or_default();

    AgentInput {
        system_prompt: format!("{}{}", state.input.system_prompt, summary),
        user_message: recent_user,
        max_turns: state.input.max_turns,
        output_schema: state.input.output_schema.clone(),
    }
}

fn truncate(s: &str, max: usize) -> String {
    if s.len() <= max {
        return s.to_string();
    }
    let mut boundary = max;
    while boundary > 0 && !s.is_char_boundary(boundary) {
        boundary -= 1;
    }
    format!("{}", &s[..boundary])
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::state::{Message, ToolCall};
    use autoagents_llm::chat::StructuredOutputFormat;
    use std::sync::Arc;
    use std::sync::atomic::{AtomicBool, Ordering};

    fn sample_schema() -> StructuredOutputFormat {
        StructuredOutputFormat {
            name: "weather_report".into(),
            description: Some("Structured weather observation".into()),
            schema: Some(serde_json::json!({
                "type": "object",
                "properties": {
                    "city": { "type": "string" },
                    "temperature_c": { "type": "number" },
                },
                "required": ["city", "temperature_c"]
            })),
            strict: Some(true),
        }
    }

    fn populated_state(turns: u32, schema: Option<StructuredOutputFormat>) -> AgentState {
        let mut state = AgentState::new(AgentInput {
            system_prompt: "sys".into(),
            user_message: "u0".into(),
            max_turns: 50,
            output_schema: schema,
        });
        for i in 1..turns {
            state.history.push(Message::user(format!("u{i}")));
            state.history.push(Message::assistant_text(format!("a{i}")));
        }
        state
    }

    #[test]
    fn sliding_window_default_uses_published_constants() {
        let m = SlidingWindowMemory::default();
        assert_eq!(m.compact_threshold(), DEFAULT_COMPACT_THRESHOLD);
        assert_eq!(m.keep_recent(), DEFAULT_KEEP_RECENT);
    }

    #[test]
    fn sliding_window_should_compact_at_threshold_boundary() {
        // Lower keep_recent first so the threshold setter's invariant check
        // accepts the smaller threshold.
        let m = SlidingWindowMemory::new()
            .with_keep_recent(3)
            .with_compact_threshold(10);
        let mut state = AgentState::new(AgentInput::default());
        // AgentState::new seeds 2 messages (system + user); top up to 10.
        while state.history.len() < 10 {
            state.history.push(Message::user("x"));
        }
        assert!(
            !m.should_compact(&state),
            "len == threshold must not trigger"
        );
        state.history.push(Message::user("x"));
        assert!(m.should_compact(&state), "len > threshold must trigger");
    }

    #[test]
    fn sliding_window_does_not_compact_short_history() {
        let m = SlidingWindowMemory::default();
        let empty = AgentState::default();
        assert!(!m.should_compact(&empty));
        let small = AgentState::new(AgentInput {
            system_prompt: "sys".into(),
            user_message: "hi".into(),
            max_turns: 5,
            output_schema: None,
        });
        assert!(!m.should_compact(&small));
    }

    #[test]
    fn sliding_window_compact_preserves_system_prompt_and_recent_user() {
        let state = populated_state(30, None);
        let m = SlidingWindowMemory::new()
            .with_compact_threshold(50)
            .with_keep_recent(10);
        let compacted = m.compact(&state);
        assert!(compacted.system_prompt.starts_with("sys"));
        assert!(
            compacted
                .system_prompt
                .contains("Prior conversation summary")
        );
        assert_eq!(compacted.max_turns, state.input.max_turns);
    }

    #[test]
    fn sliding_window_compact_preserves_output_schema() {
        let schema = sample_schema();
        let state = populated_state(30, Some(schema.clone()));
        let m = SlidingWindowMemory::new()
            .with_compact_threshold(50)
            .with_keep_recent(10);
        let compacted = m.compact(&state);
        assert_eq!(compacted.output_schema, Some(schema));
    }

    #[test]
    fn sliding_window_compact_with_custom_keep_recent() {
        // Tool-call assistant lines render with their tool names; verify the
        // synthetic summary mentions the dropped tool call when keep_recent
        // excludes it.
        let mut state = AgentState::new(AgentInput {
            system_prompt: "sys".into(),
            user_message: "u0".into(),
            max_turns: 50,
            output_schema: None,
        });
        state
            .history
            .push(Message::assistant_with_tools(vec![ToolCall {
                id: "c1".into(),
                name: "search".into(),
                args: serde_json::json!({}),
            }]));
        for i in 1..20 {
            state.history.push(Message::user(format!("u{i}")));
            state.history.push(Message::assistant_text(format!("a{i}")));
        }
        let m = SlidingWindowMemory::new()
            .with_compact_threshold(100)
            .with_keep_recent(5);
        let compacted = m.compact(&state);
        assert!(
            compacted
                .system_prompt
                .contains("assistant: called tools [search]"),
            "summary should mention dropped tool call, got: {}",
            compacted.system_prompt
        );
    }

    #[test]
    #[should_panic(expected = "keep_recent")]
    fn sliding_window_panics_on_keep_recent_ge_threshold() {
        let _ = SlidingWindowMemory::new()
            .with_compact_threshold(50)
            .with_keep_recent(100);
    }

    #[test]
    #[should_panic(expected = "compact_threshold")]
    fn sliding_window_panics_on_threshold_le_keep_recent() {
        let _ = SlidingWindowMemory::new()
            .with_keep_recent(50)
            .with_compact_threshold(10);
    }

    #[test]
    fn truncate_respects_utf8_char_boundary() {
        // 'é' is 2 bytes; slicing at byte 2 would split it and panic.
        let t = truncate("héllo world", 2);
        assert_eq!(t, "h…");
        // Already short — returned unchanged.
        assert_eq!(truncate("hi", 10), "hi");
        // Emoji boundary (4 bytes for 🦀).
        assert_eq!(truncate("🦀rust", 2), "");
    }

    #[derive(Debug, Default)]
    struct PassthroughMemory {
        invoked: AtomicBool,
    }

    impl MemoryProvider for PassthroughMemory {
        fn should_compact(&self, _state: &AgentState) -> bool {
            self.invoked.store(true, Ordering::SeqCst);
            false
        }

        fn compact(&self, state: &AgentState) -> AgentInput {
            state.input.clone()
        }
    }

    #[test]
    fn custom_memory_provider_compiles_as_arc_dyn() {
        // Compile-only — confirms the trait stays dyn-compatible.
        let _: Arc<dyn MemoryProvider> = Arc::new(PassthroughMemory::default());
    }

    #[test]
    fn custom_memory_provider_is_invoked_via_arc_dyn() {
        let provider: Arc<dyn MemoryProvider> = Arc::new(PassthroughMemory::default());
        let state = AgentState::default();
        assert!(!provider.should_compact(&state));
        // Downcast not possible through `dyn`, but the side effect on the
        // inner struct is observable via a separate Arc to the same impl.
        let owned = Arc::new(PassthroughMemory::default());
        let typed: Arc<dyn MemoryProvider> = owned.clone();
        let _ = typed.should_compact(&state);
        assert!(owned.invoked.load(Ordering::SeqCst));
    }
}