Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ Rust 2024 · MSRV 1.85 · `#![forbid(unsafe_code)]` · GPL-3.0-or-later.

- Full node catalog implemented and tested — control-flow (`condition`,
`switch`, `merge`, `split_out`, `transform`) and capability-backed (`agent`,
`tool_call`, `http_request`, `code`, `output_parser`, `sub_workflow`), plus the
`trigger` entry node.
`tool_call`, `http_request`, `code`, `output_parser`, `sub_workflow`,
`memory`), plus the `trigger` entry node.

**Reliability**

Expand Down Expand Up @@ -192,6 +192,7 @@ done
| `code` | Runs sandboxed user code (JavaScript or Python). |
| `output_parser` | Parses / validates an upstream agent's output into a structured shape. |
| `sub_workflow` | Runs another workflow as a nested sub-graph and returns its output. |
| `memory` | Reads/writes host-managed memory (recall/search/flavour/people/remember/forget). |
| `condition` | Two-way IF; emits on the `true` or `false` port. |
| `switch` | Multi-way branch keyed by an expression result. |
| `merge` | Fan-in barrier that combines multiple inputs; waits for all wired predecessors. |
Expand Down
137 changes: 134 additions & 3 deletions src/caps/mock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ use async_trait::async_trait;
use serde_json::{Value, json};

use crate::caps::{
AgentRunner, Capabilities, CodeLanguage, CodeRunner, HttpClient, LlmProvider, StateStore,
ToolInvoker, WorkflowResolver,
AgentRunner, Capabilities, CodeLanguage, CodeRunner, HttpClient, LlmProvider, MemoryProvider,
StateStore, ToolInvoker, WorkflowResolver,
};
use crate::error::{EngineError, Result};
use crate::model::WorkflowGraph;
Expand Down Expand Up @@ -77,6 +77,61 @@ impl CodeRunner for MockCode {
}
}

/// A [`MemoryProvider`] returning small, deterministic, **shaped** canned data.
///
/// Unlike a naive echo mock, each method returns a plausible result shape (a
/// `results` array for recall/search, a `traits` object for flavour, a `people`
/// array for people lookups) rather than `null`/an empty object — the same
/// "shaped mock" precedent OpenHuman's `SchemaAwareMockAgentRunner` /
/// `SchemaAwareMockLlm` set, because a naive mock made dry-runs meaningless
/// (downstream `condition`/`transform` nodes had nothing to bind to). Wired
/// into [`mock_capabilities`] by default (not behind an opt-in helper, unlike
/// [`MockAgentRunner`]) precisely so a workflow containing a `memory` node
/// dry-runs out of the box.
#[derive(Debug, Default, Clone)]
pub struct MockMemory;

#[async_trait]
impl MemoryProvider for MockMemory {
async fn recall(&self, scope: &str, query: &str, opts: Value) -> Result<Value> {
Ok(json!({
"scope": scope,
"query": query,
"opts": opts,
"results": [
{ "id": "mem_1", "text": format!("mock memory matching '{query}'"), "score": 0.92 },
{ "id": "mem_2", "text": "a second mock memory", "score": 0.81 },
],
}))
}

async fn flavour(&self, slug: &str) -> Result<Value> {
Ok(json!({
"slug": slug,
"summary": format!("mock flavour profile for '{slug}'"),
"traits": { "tone": "warm", "formality": "casual" },
}))
}

async fn people(&self, query: Option<&str>) -> Result<Value> {
Ok(json!({
"query": query,
"people": [
{ "id": "person_1", "name": "Mock Person A" },
{ "id": "person_2", "name": "Mock Person B" },
],
}))
}

async fn remember(&self, _scope: &str, _key: &str, _value: Value) -> Result<()> {
Ok(())
}

async fn forget(&self, _scope: &str, _key: &str) -> Result<()> {
Ok(())
}
}

/// A [`StateStore`] backed by an in-memory map guarded by a mutex.
#[derive(Debug, Default)]
pub struct MockStateStore {
Expand Down Expand Up @@ -129,7 +184,11 @@ impl WorkflowResolver for MockWorkflowResolver {
/// Builds a [`Capabilities`] bundle wired entirely to the mock implementations.
///
/// The bundled [`MockWorkflowResolver`] is empty; use
/// [`mock_capabilities_with_resolver`] to supply one that resolves ids.
/// [`mock_capabilities_with_resolver`] to supply one that resolves ids. Unlike
/// [`Capabilities::agent`] (which defaults `None`), [`Capabilities::memory`] is
/// wired to [`MockMemory`] by default — a `memory` node must dry-run
/// successfully out of the box; use `Capabilities { memory: None, ..caps }` to
/// exercise the "host wired no memory store" error path instead.
#[must_use]
pub fn mock_capabilities() -> Capabilities {
mock_capabilities_with_resolver(MockWorkflowResolver::default())
Expand All @@ -149,6 +208,8 @@ pub fn mock_capabilities_with_resolver(resolver: impl WorkflowResolver + 'static
// No agent registry by default: `agent` nodes use `MockLlm`. Use
// [`mock_capabilities_with_agent`] to exercise the `agent_ref` path.
agent: None,
// Wired by default (unlike `agent`) — see the doc comment above.
memory: Some(Arc::new(MockMemory)),
}
}

Expand All @@ -162,6 +223,17 @@ pub fn mock_capabilities_with_agent(agent: impl AgentRunner + 'static) -> Capabi
}
}

/// Like [`mock_capabilities`], but with a caller-supplied [`MemoryProvider`] in
/// place of the default [`MockMemory`] — for tests that need custom recall /
/// flavour / people / remember / forget behavior.
#[must_use]
pub fn mock_capabilities_with_memory(memory: impl MemoryProvider + 'static) -> Capabilities {
Capabilities {
memory: Some(Arc::new(memory)),
..mock_capabilities()
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -239,6 +311,56 @@ mod tests {
assert_eq!(py["result"], json!([1, 2, 3]));
}

#[tokio::test]
async fn mock_memory_recall_returns_shaped_results() {
let memory = MockMemory;
let out = memory
.recall("flow", "budget", json!({ "operation": "recall" }))
.await
.unwrap();
assert_eq!(out["scope"], "flow");
assert_eq!(out["query"], "budget");
let results = out["results"].as_array().expect("results array");
assert!(
!results.is_empty(),
"mock recall should return shaped results"
);
assert!(results[0].get("text").is_some());
}

#[tokio::test]
async fn mock_memory_flavour_returns_shaped_object() {
let memory = MockMemory;
let out = memory.flavour("email-tone").await.unwrap();
assert_eq!(out["slug"], "email-tone");
assert!(out["traits"].is_object());
}

#[tokio::test]
async fn mock_memory_people_returns_shaped_list() {
let memory = MockMemory;
let out = memory.people(Some("cyrus")).await.unwrap();
assert_eq!(out["query"], "cyrus");
let people = out["people"].as_array().expect("people array");
assert!(
!people.is_empty(),
"mock people should return a shaped list"
);

let out_no_query = memory.people(None).await.unwrap();
assert!(out_no_query["query"].is_null());
}

#[tokio::test]
async fn mock_memory_remember_and_forget_are_ok() {
let memory = MockMemory;
memory
.remember("flow", "k", json!({ "v": 1 }))
.await
.unwrap();
memory.forget("flow", "k").await.unwrap();
}

#[tokio::test]
async fn mock_state_store_round_trips_and_misses() {
let store = MockStateStore::default();
Expand Down Expand Up @@ -285,5 +407,14 @@ mod tests {
.unwrap()["result"],
"x"
);
assert!(
caps.memory
.as_ref()
.expect("memory wired by default")
.flavour("f")
.await
.unwrap()["slug"]
== "f"
);
}
}
106 changes: 103 additions & 3 deletions src/caps/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,94 @@ pub trait AgentRunner: Send + Sync {
-> Result<Value>;
}

/// Host-injected memory access for `memory` nodes.
///
/// A `memory` node is a **delivery surface** onto whatever durable memory store
/// the host already maintains — it exposes no capability of its own. Six config
/// `operation`s map onto five trait methods:
///
/// - `recall` and `search` both call [`recall`](Self::recall); `search` passes
/// `opts.operation == "search"` (alongside the same `query`) so a host that
/// distinguishes semantic recall from full-text search can branch on it, but
/// a host that treats them identically may ignore the distinction entirely.
/// This keeps the trait small (one read-with-a-query method rather than two
/// near-duplicates) while still letting `opts` carry the distinction through
/// to the host.
/// - `flavour` and `people` are read-only lookups with no free-text `query`
/// requirement, each with its own method.
/// - `remember` and `forget` are the two write operations.
///
/// `scope` is an opaque, host-defined string (the model layer never interprets
/// it) but the wire contract in practice is `"user"` (the caller's durable,
/// cross-flow memory — read-only from a workflow), `"flow"` (this flow's own
/// memory — the only scope `remember`/`forget` may target), and `"flows"`
/// (cross-flow read access — read-only). [`crate::validate`] enforces the hard
/// invariant that a `remember`/`forget` operation may never carry
/// `scope: "user"`, structurally, before a run ever starts — that boundary is
/// NOT re-checked here, so a host implementing this trait directly (bypassing
/// the node + validator) must not treat that check as already done.
///
/// This capability is **optional**, matching the [`AgentRunner`] precedent:
/// hosts without a memory store leave [`Capabilities::memory`] `None`, and a
/// `memory` node then fails at run time with a capability error rather than
/// silently no-opping (there is no meaningful fallback for a read/write memory
/// call the way `agent` falls back to a bare completion).
#[async_trait]
pub trait MemoryProvider: Send + Sync {
/// Recalls memory matching `query` within `scope`.
///
/// Backs both the `recall` and `search` node operations; `opts` carries
/// `"operation"` (`"recall"` or `"search"`) plus any of the node's optional
/// `limit` / `min_score` config, each present only when the author set it.
/// The return shape is host-defined — typically an object with a `results`
/// (or similar) array — and is passed through to the node's output
/// envelope unchanged.
///
/// # Errors
/// Returns an [`EngineError::Capability`](crate::error::EngineError::Capability)
/// when the scope is unknown to the host or the lookup fails.
async fn recall(&self, scope: &str, query: &str, opts: Value) -> Result<Value>;

/// Looks up a named "flavour" (a host-defined ask/persona/style profile,
/// e.g. `"email-tone"`) by its slug.
///
/// # Errors
/// Returns an [`EngineError::Capability`](crate::error::EngineError::Capability)
/// when `slug` is unknown to the host.
async fn flavour(&self, slug: &str) -> Result<Value>;

/// Looks up people the host's memory knows about, optionally narrowed by a
/// free-text `query`; `None` returns the host's default listing.
///
/// # Errors
/// Returns an [`EngineError::Capability`](crate::error::EngineError::Capability)
/// when the lookup fails.
async fn people(&self, query: Option<&str>) -> Result<Value>;

/// Persists `value` under `key` within `scope`.
///
/// Callers (the `memory` node, via the validator) must never pass
/// `scope: "user"` here — see the trait-level docs. `value` is caller
/// (workflow-author / upstream node) controlled; hosts should apply the
/// same taint/provenance handling they use for any externally-influenced
/// write.
///
/// # Errors
/// Returns an [`EngineError::Capability`](crate::error::EngineError::Capability)
/// when the scope is unknown to the host or the write fails.
async fn remember(&self, scope: &str, key: &str, value: Value) -> Result<()>;

/// Deletes the memory stored under `key` within `scope`.
///
/// Same `scope: "user"` restriction as [`remember`](Self::remember).
/// Deleting an already-absent `key` is not an error.
///
/// # Errors
/// Returns an [`EngineError::Capability`](crate::error::EngineError::Capability)
/// when the scope is unknown to the host or the delete fails.
async fn forget(&self, scope: &str, key: &str) -> Result<()>;
}

/// Invokes a named integration tool (e.g. a curated Composio action).
#[async_trait]
pub trait ToolInvoker: Send + Sync {
Expand Down Expand Up @@ -126,9 +214,11 @@ pub trait StateStore: Send + Sync {
/// The bundle of capabilities handed to the engine for a run.
///
/// Construct one per run from the host's concrete implementations. It carries
/// all five host-injected capabilities: the [`LlmProvider`], [`ToolInvoker`],
/// [`HttpClient`], [`CodeRunner`], and [`StateStore`]. Nodes reach each one
/// through `ctx.caps` during execution.
/// every host-injected capability: the always-present [`LlmProvider`],
/// [`ToolInvoker`], [`HttpClient`], [`CodeRunner`], [`StateStore`], and
/// [`WorkflowResolver`], plus the optional [`AgentRunner`] and
/// [`MemoryProvider`]. Nodes reach each one through `ctx.caps` during
/// execution.
#[derive(Clone)]
pub struct Capabilities {
/// LLM provider for agent / output-parser nodes.
Expand Down Expand Up @@ -156,6 +246,12 @@ pub struct Capabilities {
/// [`LlmProvider`] completion. `None` on hosts without an agent registry, in
/// which case `agent` nodes always use [`LlmProvider`].
pub agent: Option<Arc<dyn AgentRunner>>,
/// Optional host-managed memory access for `memory` nodes. `None` on hosts
/// without a memory store, in which case a `memory` node fails at run time
/// with a capability error (there is no meaningful no-op fallback for a
/// read/write memory call). See [`MemoryProvider`] for the `scope`
/// contract and the `remember`/`forget` write restriction.
pub memory: Option<Arc<dyn MemoryProvider>>,
}

#[cfg(test)]
Expand Down Expand Up @@ -190,6 +286,10 @@ mod tests {
assert!(Arc::ptr_eq(&caps.code, &clone.code));
assert!(Arc::ptr_eq(&caps.state, &clone.state));
assert!(Arc::ptr_eq(&caps.resolver, &clone.resolver));
assert!(Arc::ptr_eq(
caps.memory.as_ref().expect("mock wires memory"),
clone.memory.as_ref().expect("mock wires memory")
));
}

#[tokio::test]
Expand Down
Loading