Summary
Add a FactExtractor — a Runnable<FactExtractionInput, Vec<Fact>> that distills raw agent output into atomic, reusable facts using a Cognis LLM client and StructuredOutputParser. This is the write-time extraction step that memory systems need before storing agent observations.
Motivation
Any application building long-term memory from agent output needs to reduce verbose text into atomic facts before storing. Without this step, you either store raw text (noisy retrieval) or build the extraction logic yourself in every application. This is equivalent to what mem0 and Zep provide as their core managed service. Building it once in Cognis gives all framework users the capability for free, and prompt quality improves once for everyone.
API Design
// crates/cognis/src/agent/fact_extractor.rs
use cognis_core::runnable::{Runnable, RunnableConfig};
use cognis_core::output_parsers::StructuredOutputParser;
use cognis_llm::Client;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum FactKind {
Rule, // standing instruction that must always be respected
Preference, // softer guideline, follow unless there is a reason not to
Context, // situational information that informs decisions
Decision, // past choice with rationale (informative, not prescriptive)
Observation, // ongoing state worth being aware of
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Fact {
pub content: String, // self-contained atomic statement
pub kind: FactKind,
pub importance: f32, // 0.0 – 1.0; caller uses for importance scoring on store
}
#[derive(Debug, Clone)]
pub struct FactExtractionInput {
pub text: String,
pub context_hints: Vec<String>, // e.g. ["project: stev API", "goal: billing v2"]
pub max_facts: usize, // default: 7
}
impl Default for FactExtractionInput {
fn default() -> Self {
Self { text: String::new(), context_hints: vec![], max_facts: 7 }
}
}
pub struct FactExtractor {
client: Arc<Client>,
prompt_template: String, // overridable via builder
}
pub struct FactExtractorBuilder {
client: Arc<Client>,
prompt_template: Option<String>,
model: Option<String>,
}
impl FactExtractorBuilder {
pub fn new(client: Arc<Client>) -> Self { ... }
pub fn prompt_template(mut self, tpl: impl Into<String>) -> Self { ... }
pub fn model(mut self, model: impl Into<String>) -> Self { ... }
pub fn build(self) -> FactExtractor { ... }
}
impl Runnable<FactExtractionInput, Vec<Fact>> for FactExtractor {
async fn invoke(
&self,
input: FactExtractionInput,
config: RunnableConfig,
) -> cognis_core::error::Result<Vec<Fact>>;
}
Default Extraction Prompt
The built-in prompt should produce high-quality, actionable facts:
You are extracting reusable memory facts from an AI agent's completed work.
{context_section}
Agent output:
---
{text}
---
Extract up to {max_facts} atomic facts that would be useful for future agents working
on similar tasks. Each fact must be:
- Self-contained (understandable without this output)
- Specific (not "used best practices" — name which ones)
- Actionable (tells a future agent something concrete)
Classify each fact as one of:
- "rule" — a standing decision that must always be followed
- "preference" — a softer guideline; follow unless there is a reason not to
- "context" — situational information that informs decisions
- "decision" — a past choice with rationale (informative, not prescriptive for future)
- "observation" — an ongoing state worth being aware of
Return a JSON array only, no prose:
[
{ "content": "...", "kind": "rule|preference|context|decision|observation", "importance": 0.0–1.0 }
]
If the output contains no useful facts, return an empty array: []
Implementation Notes
- Use
StructuredOutputParser<Vec<Fact>> from cognis-core to parse the LLM response
- If parsing fails, log a warning and return an empty
Vec — never propagate extraction failures to the caller
context_section is rendered as "Context:\n- {hint}\n- {hint}" when hints are non-empty, omitted entirely when empty
- The prompt template should be overridable via
FactExtractorBuilder::prompt_template for callers who need domain-specific extraction behavior
Module Location
crates/cognis/src/agent/fact_extractor.rs
Re-export from crates/cognis/src/agent/mod.rs:
pub mod fact_extractor;
pub use fact_extractor::{Fact, FactExtractor, FactExtractorBuilder, FactExtractionInput, FactKind};
Dependencies
cognis-core: Runnable, StructuredOutputParser, ChatPromptTemplate (all existing)
cognis-llm: Client (existing)
serde, serde_json: already in workspace
- No new crate dependencies required
Tests to Write
// crates/cognis/tests/fact_extractor.rs
#[tokio::test]
async fn extracts_rule_from_architectural_decision() {
// Given: output describing a monolithic architecture choice
// When: FactExtractor runs
// Then: at least one Fact with kind=Rule about architecture is returned
}
#[tokio::test]
async fn returns_empty_vec_on_unparseable_output() {
// Given: LLM returns malformed JSON
// When: FactExtractor runs
// Then: returns Ok(vec![]) without propagating the error
}
#[tokio::test]
async fn respects_max_facts_limit() {
// Given: max_facts = 3 and output that could generate many facts
// When: FactExtractor runs
// Then: at most 3 facts are returned
}
#[tokio::test]
async fn context_hints_appear_in_prompt() {
// Use an interceptor or mock client to verify the rendered prompt
// contains the provided context_hints
}
Example Usage
use cognis::agent::{FactExtractor, FactExtractionInput};
use cognis_llm::{Client, ClientBuilder, Provider};
use std::sync::Arc;
let client = Arc::new(
ClientBuilder::new()
.provider(Provider::Anthropic)
.api_key("sk-ant-...")
.model("claude-3-5-haiku-20241022")
.build()?
);
let extractor = FactExtractor::builder(client).build();
let facts = extractor.invoke(
FactExtractionInput {
text: "Chose monolithic architecture for the API service. Microservices would require separate deployments and inter-service networking that the team cannot operate at current headcount.".to_string(),
context_hints: vec!["project: stev API".to_string()],
max_facts: 5,
},
Default::default(),
).await?;
// facts: [Fact { content: "Monolithic architecture chosen for API service", kind: Decision, importance: 0.9 }]
Acceptance Criteria
Related
- Tracked in stev roadmap: docs/specs/0012-memory-layer.md (Phase 2)
- Companion issue: DedupVectorStore (#next)
Summary
Add a
FactExtractor— aRunnable<FactExtractionInput, Vec<Fact>>that distills raw agent output into atomic, reusable facts using a Cognis LLM client andStructuredOutputParser. This is the write-time extraction step that memory systems need before storing agent observations.Motivation
Any application building long-term memory from agent output needs to reduce verbose text into atomic facts before storing. Without this step, you either store raw text (noisy retrieval) or build the extraction logic yourself in every application. This is equivalent to what mem0 and Zep provide as their core managed service. Building it once in Cognis gives all framework users the capability for free, and prompt quality improves once for everyone.
API Design
Default Extraction Prompt
The built-in prompt should produce high-quality, actionable facts:
Implementation Notes
StructuredOutputParser<Vec<Fact>>fromcognis-coreto parse the LLM responseVec— never propagate extraction failures to the callercontext_sectionis rendered as "Context:\n- {hint}\n- {hint}" when hints are non-empty, omitted entirely when emptyFactExtractorBuilder::prompt_templatefor callers who need domain-specific extraction behaviorModule Location
Re-export from
crates/cognis/src/agent/mod.rs:Dependencies
cognis-core:Runnable,StructuredOutputParser,ChatPromptTemplate(all existing)cognis-llm:Client(existing)serde,serde_json: already in workspaceTests to Write
Example Usage
Acceptance Criteria
FactExtractorimplementsRunnable<FactExtractionInput, Vec<Fact>>Ok(vec![])with a tracing warning, never an errorcognis::agentexamples/memory/fact_extraction.rsRelated