Skip to content

Commit ed88d6b

Browse files
authored
feat: introduce pluggable memory providers for agent workflows (#29)
* feat: introduce pluggable memory providers for agent workflows - Added `MemoryProvider` trait to allow customizable state compaction strategies. - Implemented default `SlidingWindowMemory` provider to maintain legacy behavior. - Updated `AgentWorkerBuilder` to support configurable memory providers. - Removed hardcoded compaction logic from `workflow.rs` and `state.rs`, and moved to `memory.rs`. * feat: add `tunable_memory_agent` example and enhance documentation for memory providers - Introduced the `tunable_memory_agent` example to demonstrate custom and aggressive `SlidingWindowMemory` configurations. - Expanded README with a "Pluggable memory backends" section describing memory provider traits and usage. - Added documentation for `MemoryProvider` determinism requirements in `AGENTS.md`. - Enforced consistency checks for `MemoryProvider` across workers in multi-worker setups.
1 parent 93f02f3 commit ed88d6b

11 files changed

Lines changed: 903 additions & 134 deletions

File tree

AGENTS.md

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,22 +38,23 @@ Override the LLM endpoint with `OPENAI_BASE_URL` (defaults to `https://api.opena
3838

3939
**Module map:**
4040
- [src/lib.rs](src/lib.rs) — module re-exports.
41-
- [src/builder.rs](src/builder.rs)`AgentWorkerBuilder` fluent builder; wires LLM + tools into a Temporal `Worker`.
41+
- [src/builder.rs](src/builder.rs)`AgentWorkerBuilder` fluent builder; wires LLM + tools + memory provider into a Temporal `Worker`.
4242
- [src/workflow.rs](src/workflow.rs)`AgentWorkflow` with `#[run]`, `#[signal] add_user_message`, `#[query] get_state`, `#[query] turn_count`. Owns the ReAct loop.
4343
- [src/activities.rs](src/activities.rs)`AgentActivities::llm_chat` and `AgentActivities::execute_tool`. The *only* place LLM providers and tool implementations execute.
4444
- [src/llm.rs](src/llm.rs) — translation between local `Message`/`ToolSchema` types and AutoAgents `ChatMessage`/`LlmTool`; native-tool-call parsing with fenced-JSON fallback. The only file that touches `autoagents_llm` types in the hot path (`src/llm.rs:6`).
45-
- [src/state.rs](src/state.rs)`AgentInput`, `AgentOutput`, `AgentState`, `Message`, `ToolCall`, `ToolResult`, `ToolSchema`, `LlmResponse`, `StopReason`, plus `compact()`.
45+
- [src/state.rs](src/state.rs)`AgentInput`, `AgentOutput`, `AgentState`, `Message`, `ToolCall`, `ToolResult`, `ToolSchema`, `LlmResponse`, `StopReason`.
46+
- [src/memory.rs](src/memory.rs)`MemoryProvider` trait, default `SlidingWindowMemory` impl, and the `compact_sliding_window` kernel. Pluggable compaction strategy consulted by the workflow before every turn.
4647
- [src/tool.rs](src/tool.rs)`ToolRegistry` (immutable name→impl map) and its builder.
4748
- [src/error.rs](src/error.rs)`AgentError` with `is_retryable()` to distinguish transient vs. permanent.
48-
- [src/prelude.rs](src/prelude.rs) — convenience re-exports including AutoAgents traits (`ToolT`, `LLMProvider`, `ToolRuntime`, `ToolCallError`).
49+
- [src/prelude.rs](src/prelude.rs) — convenience re-exports including AutoAgents traits (`ToolT`, `LLMProvider`, `ToolRuntime`, `ToolCallError`) and memory types (`MemoryProvider`, `SlidingWindowMemory`).
4950

50-
**Public API surface (what a user actually touches):** `AgentWorkerBuilder`, `AgentWorkflow`, `AgentInput`/`AgentOutput`, `ToolRegistry`. Users supply their own `Arc<dyn LLMProvider>` and `Arc<dyn ToolT>` from AutoAgents.
51+
**Public API surface (what a user actually touches):** `AgentWorkerBuilder`, `AgentWorkflow`, `AgentInput`/`AgentOutput`, `ToolRegistry`, `MemoryProvider`/`SlidingWindowMemory`. Users supply their own `Arc<dyn LLMProvider>` and `Arc<dyn ToolT>` from AutoAgents.
5152

5253
**Non-obvious behaviors to preserve when editing:**
5354

54-
- **History compaction.** When `AgentState::history.len()` exceeds `CONTINUE_AS_NEW_THRESHOLD = 200` (`src/workflow.rs:36`), the workflow calls `continue_as_new` with a compacted state: summary prepended to the system prompt, last 20 messages kept (`src/state.rs::compact`). Any change to the message shape needs to round-trip through `compact()`.
55+
- **History compaction is pluggable.** The workflow consults `MemoryProvider::should_compact` before every turn; on `true` it calls `MemoryProvider::compact` and `continue_as_new` with the returned `AgentInput`. Default provider is `SlidingWindowMemory` (`compact_threshold = 200`, `keep_recent = 20`), preserving the legacy hardcoded behavior. Override via `AgentWorkerBuilder::memory(Arc::new(SlidingWindowMemory::new().with_compact_threshold(N).with_keep_recent(K)))` or supply your own `Arc<dyn MemoryProvider>`. Trait impls MUST be pure and sync — they run inside the deterministic workflow body. The kernel summarizer lives at `src/memory.rs::compact_sliding_window`; any change to the `Message` shape needs to round-trip through it.
5556
- **Tool error semantics.** Tool-side failures return `Ok(ToolResult { error: Some(...) })` so the LLM can see and recover from them (`src/activities.rs:59-88`). Only infrastructure errors (missing tool, serde failure) surface as activity `Err`, which Temporal retries.
56-
- **`WORKER_TOOL_CATALOG`.** A process-global `OnceCell` set once at worker init in `build_worker` (`src/builder.rs:34`). The deterministic workflow body reads it on every replay, so it must be set before the worker starts and never mutated after.
57+
- **Process-global worker config (`WORKER_TOOL_CATALOG`, `WORKER_MEMORY`).** Two `OnceCell`s in `src/builder.rs` published by `build_worker`. The deterministic workflow body reads them on every replay, so they must be set before the worker starts and never mutated after. Building a second worker in the same process with a *different* catalog (compared by `PartialEq`) or a different memory `Arc` (compared by `Arc::ptr_eq`) returns `AgentError::Other` — multi-worker setups in one process must share the same `Arc<dyn MemoryProvider>` and register identical tools in the same order.
5758
- **Activity timeouts.** Set inside `AgentWorkflow::run` at `src/workflow.rs:66-74`: LLM activity 120s start-to-close / 30s heartbeat, tool activity **3600s** start-to-close (generous on purpose — supports human-in-the-loop tools that block on stdin/HTTP/async-completion).
5859
- **Mid-conversation user input.** The `add_user_message` signal pushes into `pending_user_messages`, drained at the top of each loop iteration (`src/workflow.rs:145-152`). Don't mutate `history` directly from signal handlers — that races with the in-flight `llm_chat` activity.
5960
- **Dual LLM response parsing.** `src/llm.rs` tries native tool calls first, then falls back to a fenced `\`\`\`tool_calls` JSON block so non-OpenAI providers still work.
@@ -62,6 +63,34 @@ Override the LLM endpoint with `OPENAI_BASE_URL` (defaults to `https://api.opena
6263
- Tools must be side-effect-safe on retry.
6364
- `LLMProvider` and `ToolT` impls must be `Send + Sync + 'static`.
6465
- Never invoke `LLMProvider` or `ToolT` from workflow code — only from activities.
66+
- `MemoryProvider` impls must be pure, sync, and stateless (config-only) — `should_compact` and `compact` are called inside the workflow body and must return identical results on replay for the same `AgentState`.
67+
68+
## Documentation maintenance
69+
70+
After any change large enough to alter the public API surface, observable
71+
behavior, defaults, or feature set, update the user-facing docs in the same
72+
PR — stale docs are worse than no docs because they actively mislead.
73+
Specifically:
74+
75+
- **[AGENTS.md](AGENTS.md)** (this file) — update the module map, public API
76+
surface line, non-obvious behaviors, and determinism contract whenever any
77+
of them change. Add new module entries here as soon as you create them.
78+
- **[README.md](README.md)** — update the features list, examples list,
79+
user-facing determinism contract, and any code snippets affected by API
80+
changes. If you add a feature with its own knobs (caching, fallback,
81+
memory backends, etc.), give it a short dedicated section like
82+
"Pluggable memory backends" so users can find it without reading the
83+
source.
84+
- **[examples/](examples/)** — when adding a new top-level feature, ship
85+
one runnable example that exercises it (model the new example on
86+
`simple_math_agent` — same worker/client/status mode template, same
87+
three-terminal flow). Register it in `Cargo.toml` under a new
88+
`[[example]]` entry and add a one-line description plus a runnable
89+
invocation to README.md's "Running the examples" block.
90+
91+
Rule of thumb: if you touched `src/lib.rs` re-exports, `src/prelude.rs`, or
92+
`AgentWorkerBuilder`'s public API, you owe at least one edit to each of the
93+
three above.
6594

6695
## Version pins
6796

Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,3 +70,7 @@ path = "examples/pipelined_math_agent/main.rs"
7070
[[example]]
7171
name = "structured_output_agent"
7272
path = "examples/structured_output_agent/main.rs"
73+
74+
[[example]]
75+
name = "tunable_memory_agent"
76+
path = "examples/tunable_memory_agent/main.rs"

README.md

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ LLM tokens.
5959
- `AgentWorkerBuilder` for one-line worker setup.
6060
- Provider-agnostic: bring your own `Arc<dyn LLMProvider>` (OpenAI,
6161
Anthropic, Ollama, etc. — anything supported by `autoagents_llm`).
62+
- **Pluggable memory backends** via the `MemoryProvider` trait — default
63+
`SlidingWindowMemory` matches the legacy hardcoded behavior; swap in
64+
custom strategies through `AgentWorkerBuilder::memory`. See
65+
[Pluggable memory backends](#pluggable-memory-backends).
6266
- **Human-in-the-loop as a regular tool** — the library does not
6367
special-case any tool name. See
6468
[Human-in-the-loop tools](#human-in-the-loop-tools).
@@ -159,16 +163,62 @@ by it.
159163
See `examples/pipelined_math_agent` for a runnable demo (`add` tool +
160164
`PipelineBuilder(CacheLayer → FallbackLayer)` around two OpenAI models).
161165

166+
## Pluggable memory backends
167+
168+
History compaction is governed by an `Arc<dyn MemoryProvider>` published
169+
to the worker via `AgentWorkerBuilder::memory`. The default — used when
170+
`.memory(...)` is not called — is `SlidingWindowMemory` with
171+
`compact_threshold = 200` and `keep_recent = 20`, which matches the
172+
legacy hardcoded behavior.
173+
174+
```rust,ignore
175+
use std::sync::Arc;
176+
use temporal_agent_rs::prelude::*;
177+
178+
let memory: Arc<dyn MemoryProvider> = Arc::new(
179+
SlidingWindowMemory::new()
180+
.with_compact_threshold(50)
181+
.with_keep_recent(10),
182+
);
183+
184+
AgentWorkerBuilder::new(client)
185+
.llm(llm)
186+
.tool(my_tool)
187+
.memory(memory)
188+
.build_worker(&runtime)?;
189+
```
190+
191+
**Trait contract.** Implementations MUST be pure and synchronous —
192+
`should_compact` and `compact` run inside the deterministic workflow
193+
body and must return identical results on every replay for the same
194+
`AgentState`. Per-conversation state belongs in `AgentState` (which
195+
Temporal persists in workflow history), never in fields on the provider.
196+
197+
**Multi-worker setups.** Running multiple workers in the same process on
198+
the same queue requires sharing the *same* `Arc<dyn MemoryProvider>`
199+
the builder fails fast (via `Arc::ptr_eq`) on mismatching instances to
200+
prevent the second worker from silently inheriting the first worker's
201+
provider while replay diverges.
202+
203+
See `examples/tunable_memory_agent` for a runnable demo of a tuned
204+
`SlidingWindowMemory` plus a minimal custom `MemoryProvider` impl
205+
(`KeepEverythingMemory`) gated behind a `KEEP_EVERYTHING=1` env switch.
206+
162207
## Running the examples
163208

164-
Three examples ship with the crate:
209+
Five examples ship with the crate:
165210

166211
- `simple_math_agent` — minimal autonomous loop with a single `add` tool.
167212
- `interactive_math_agent` — adds an `ask_user` tool so the agent can pause
168213
for human input on the worker's stdin.
169214
- `pipelined_math_agent` — same `add` tool, but the provider is wrapped with
170215
`PipelineBuilder → CacheLayer → FallbackLayer` to demonstrate the
171216
composition pattern described above.
217+
- `structured_output_agent` — forces a JSON-schema-shaped final answer via
218+
`AgentInput::output_schema`.
219+
- `tunable_memory_agent` — demonstrates a tuned `SlidingWindowMemory` and a
220+
custom `MemoryProvider` impl; aggressive thresholds make `continue_as_new`
221+
compaction observable on a short conversation.
172222

173223
```bash
174224
# Terminal 1: local Temporal dev server (install via `brew install temporal` or temporal.io)
@@ -190,6 +240,17 @@ cargo run --example interactive_math_agent -- client
190240
# run the client twice with the same prompt to observe the cache layer.
191241
OPENAI_API_KEY=sk-... cargo run --example pipelined_math_agent -- worker
192242
cargo run --example pipelined_math_agent -- client
243+
244+
# Structured output — final answer constrained by a JSON schema.
245+
OPENAI_API_KEY=sk-... cargo run --example structured_output_agent -- worker
246+
cargo run --example structured_output_agent -- client
247+
248+
# Pluggable memory backends — aggressive SlidingWindowMemory so compaction
249+
# fires mid-run. Use the `status` sub-command to watch history.len() and the
250+
# "Prior conversation summary" marker appear in the system prompt.
251+
OPENAI_API_KEY=sk-... cargo run --example tunable_memory_agent -- worker
252+
cargo run --example tunable_memory_agent -- client
253+
cargo run --example tunable_memory_agent -- status
193254
```
194255

195256
The Temporal Web UI is at http://localhost:8233. Click into the workflow to
@@ -319,6 +380,10 @@ When you write tools and provider configs:
319380
- Never call your `LLMProvider` or your `ToolT` from inside workflow code.
320381
The workflow holds tools by name; the only path to invocation is the
321382
`execute_tool` activity.
383+
- `MemoryProvider` impls must be **pure, sync, and stateless** (config
384+
only) — `should_compact` and `compact` run inside the workflow body and
385+
must return identical results on replay for the same `AgentState`. Keep
386+
conversation state in `AgentState`, never on the provider.
322387

323388
## Version compatibility
324389

0 commit comments

Comments
 (0)