Agent Tools¶
uniko splits memory writes into two kinds of work. Pipelines handle what can be inferred from a message stream — Observations, Facts, Entities, Topics extracted by the ingest path. Agent tools handle what only the agent can decide to record: the goal it is pursuing, the task it broke that goal into, the action it just ran, the episode it judged worth remembering. No extractor can read these off a transcript, so the cognitive stack exposes them as explicit calls.
Subjective state vs. inferred state
If a claim was said in a Message, let the ingest pipeline extract it. Reach for an agent tool when the agent holds knowledge or intent that was never in the message stream — a goal it set itself, a fact it inferred, a correction it must apply now rather than waiting for consolidation.
Two layers¶
- The facade — goals and tasks are first-class on the
Agent: reach them throughagent.goals(). This is the surface most callers want. - Recording primitives — facts, observations, episodes, and actions are free functions over a
KnowledgeBase(the engine handle the facade wraps). They're for engine-level callers who hold aKnowledgeBasedirectly:tool(&kb, agent_id, params)(or(&kb, params)forassert_fact/invalidate_fact).
// The facade params for goals and tasks come from `uniko_api::tools`.
use uniko_api::tools::{CreateGoalParams, CreateTaskParams};
// The recording free functions and their params live on the `uniko_memory`
// crate root — `uniko_api::tools` intentionally guards them out and only
// re-exports the facade surface.
use uniko_memory::{
RecordActionParams, RecordEpisodeParams,
AddObservationParams, AssertFactParams, InvalidateFactParams,
create_goal, create_task, record_action, record_episode,
add_observation, assert_fact, invalidate_fact,
};
The Participant must exist first
Tools that take an agent_id resolve it to a Participant node up front and fail fast with
UnikoError::Storage if it is missing. session.observe(...) creates the agent's Participant on
first sight; a pure-planning agent should observe once (or seed the Participant) before recording.
Goals and tasks — agent.goals()¶
A Goal is a top-level objective the agent committed to; a Task is a concrete unit of work that
advances it. agent.goals() covers their full lifecycle — create, transition, read by phase, and
expand a goal's working context.
use serde_json::json;
use uniko_memory::CreateGoalParams;
let agent = memory.agent("assistant");
// Create — only `title` is required; metrics/guardrails take arbitrary JSON.
let goal = agent.goals().create(CreateGoalParams {
goal_id: Some("g-billing".into()),
title: "Migrate billing to the new ledger".into(),
metrics: Some(json!({ "max_downtime_minutes": 5 })),
..Default::default()
}).await?;
// Transition — status defaults to "active"; complete records the result.
agent.goals().start("g-billing").await?;
agent.goals().complete("g-billing", Some(json!({ "downtime_minutes": 2 }))).await?;
Lifecycle phases are derived from the free-form status (+ completed_at):
GoalPhase::{Planned, Active, Completed, Abandoned}; TaskPhase::{Planned, Active, Completed,
Blocked}. Read sliced by phase:
let active = agent.goals().active().await?; // currently pursued
let planned = agent.goals().planned().await?; // future work
let completed = agent.goals().completed().await?; // history + results
let one = agent.goals().get("g-billing").await?; // Option<GoalView>
Each GoalView carries { goal_id, title, status, phase, deadline?, completed_at?, metrics? };
metrics holds the success criteria you set at creation, merged with the result you pass to
complete. Transitions return Result<bool> — false for an unknown id, not an error.
Tasks under a goal¶
use uniko_memory::CreateTaskParams;
agent.goals().create_task(CreateTaskParams {
title: "Backfill the ledger from the legacy table".into(),
priority: Some(0.8),
goal_id: Some("g-billing".into()), // PART_OF, best-effort
..Default::default()
}).await?;
let tasks = agent.goals().tasks_of("g-billing").await?; // Vec<TaskView>
agent.goals().complete_task("t-1").await?;
| Edge | Target | Required | From params |
|---|---|---|---|
ASSIGNED_TO |
Participant | yes | the agent |
PART_OF |
Goal | best-effort | goal_id |
DEPENDS_ON |
Task | best-effort | depends_on_task_id |
SUBTASK_OF |
Task | best-effort | subtask_of_task_id |
Working context for a goal¶
agent.goals().context(goal_id) answers "what is in front of the agent for this goal right now?" It
expands the goal outward through its Tasks, Sessions, Messages, Facts, and Entities and returns a
typed GoalContext. Computed live, it reflects the current graph on every call; an unknown goal
returns None.
graph LR
G[Goal] -->|PART_OF| T[Tasks]
G --> S[Sessions]
T --> S
S --> M[Messages]
M --> F[Facts]
M --> E[Entities]
F --> E
if let Some(ctx) = agent.goals().context("g-billing").await? {
// ctx.goal: GoalView, ctx.tasks: Vec<TaskView>,
// ctx.sessions / ctx.recent_messages / ctx.facts / ctx.entities: Vec<String>
}
Bring your own IDs
Every creation call accepts an optional pre-set external key (goal_id, task_id). Leave it
None and uniko generates a UUID v7; set it to integrate with an external ID space.
Doing: Actions and Episodes¶
These are engine-level recording primitives — free functions over a KnowledgeBase.
record_action¶
An Action is a concrete tool call — a shell command, file edit, API request, RPC. It carries
input/output payloads and links to the artifacts it produces and the messages that triggered it.
record_action wires a mandatory PERFORMED_BY edge; optional TRIGGERED_BY (Message),
IN_SESSION (Session), and NEXT_ACTION (previous Action) edges are best-effort. It returns:
When output exceeds UnikoConfig::action_output_artifact_threshold (default 256 tokens), the
full payload overflows into an Artifact linked by PRODUCED and the Action keeps a short stub —
overflow_artifact carries that node's id.
use serde_json::json;
use uniko_memory::{record_action, RecordActionParams};
let result = record_action(&kb, "assistant", RecordActionParams {
action_type: "shell".into(),
input: Some(json!({ "cmd": "cargo test --workspace" })),
output: Some(json!({ "stdout": "…", "exit": 0 })),
status: Some("success".into()),
..Default::default()
}).await?;
record_episode¶
An Episode captures the agent's subjective experience: what it did, the outcome, the state at that
moment. Episodes feed procedure promotion, the relevance-decay rule, and Phase 2 of recall.
action_type and outcome are the meaningful inputs; state/delta are free-form JSON. The first
non-empty string at topic/question/description/summary/input in state becomes the
embedding text. importance ([0.0, 1.0], default 0.5) drives decay. The tool wires
RECORDED_BY, a FOLLOWED_BY edge within the one-hour continuity window, and best-effort INVOLVES
edges to involved_action_ids.
use serde_json::json;
use uniko_memory::{record_episode, RecordEpisodeParams};
let episode = record_episode(&kb, "assistant", RecordEpisodeParams {
action_type: "build".into(),
outcome: Some("failure".into()),
state: Some(json!({ "topic": "ledger backfill build", "error": "missing column" })),
importance: Some(0.7),
..Default::default()
}).await?;
Asserting knowledge: Observations and Facts¶
add_observation¶
An Observation is an atomic claim tied to the Message it was drawn from. The ingest pipeline
extracts these automatically; add_observation lets an agent add one explicitly. Because every
Observation is anchored to a Message via OBSERVED_IN, it requires a message_id. confidence
defaults to 1.0 (agent-asserted).
use uniko_memory::{add_observation, AddObservationParams};
let obs = add_observation(&kb, "assistant", AddObservationParams {
message_id: "m-12".into(),
content: "Caroline plays clarinet".into(),
subject: "Caroline".into(),
predicate: Some("plays".into()),
object: Some("clarinet".into()),
..Default::default()
}).await?;
assert_fact¶
Most Facts are derived by consolidation. assert_fact lets an agent assert one directly. The
triple (subject, predicate, object) is the Fact's identity — asserting the same triple reinforces
rather than duplicates. Returns a FactUpsert (was it new, current observation count).
use uniko_memory::{assert_fact, AssertFactParams};
let upsert = assert_fact(&kb, AssertFactParams {
subject: "user".into(),
predicate: "prefers".into(),
object: Some("dark mode".into()),
..Default::default()
}).await?;
invalidate_fact¶
invalidate_fact retracts a Fact by closing its bitemporal validity interval at now — the Fact is
not deleted, it stops being valid. When replacement_fact_id resolves, the supersession is recorded
via an INVALIDATES edge carrying the optional reason.
use uniko_memory::{invalidate_fact, InvalidateFactParams};
invalidate_fact(&kb, InvalidateFactParams {
fact_id: "f-darkmode".into(),
replacement_fact_id: Some("f-lightmode".into()),
reason: Some("user switched to light mode".into()),
..Default::default()
}).await?;
Bitemporal, not destructive
Assertion routes through upsert_fact_by_triple (idempotent on the triple); retraction closes
the bitemporal interval. History is preserved.
Answering and summarizing¶
agent.answer — the easy path¶
agent.answer(question) runs recall, calls the LLM you configured with .llm(...), and returns an
Answer with the grounding context and citations(). No closure, no
prompt plumbing.
let ans = agent.answer("Which ledger did billing migrate to?").await?;
println!("{}", ans.text);
for src in ans.citations() { /* what grounded the answer */ }
answer_query — bring your own LLM¶
When you want to own model selection and prompt construction, the low-level answer_query runs
recall, hands the bundle to your generator closure (returning a GeneratedAnswer), and — when
you opt in with QueryRecordOptions — records the query as an Episode that procedure promotion learns
from. It returns the same Answer.
use uniko_memory::{answer_query, GeneratedAnswer, QueryRecordOptions};
let answer = answer_query(
&kb,
"Which ledger did billing migrate to?",
&recall_config,
|bundle, question| async move {
let text = my_llm_call(bundle, question).await?;
Ok(GeneratedAnswer { text, input_tokens: None, output_tokens: None, model: Some("gpt-4o-mini".into()) })
},
Some(QueryRecordOptions { participant_id: "assistant".into(), outcome: Some("success".into()), ..Default::default() }),
).await?;
// answer.text — the synthesized answer
// answer.context — the recall bundle that grounded it
// answer.recorded_episode — Some(id) when recording succeeded
Recording is opt-in and never blocks the answer
Pass None to skip recording. A recording failure is logged at debug and surfaces as
recorded_episode = None — it never breaks the user-visible answer.
session.summarize¶
A Summary is a compact, embedded synopsis of a Session that Phase-3 recall falls back to when
finer-grained Observations and Facts under-cover a query. session.summarize() builds or refreshes
it — extractive and deterministic by default (offline, no model), abstractive when an LLM is
configured. Idempotent on a stable id; returns None when there's nothing to summarize. (The
free-function form, generate_session_summary(&kb, session_id, now, llm_alias), is available
engine-side.)
See also¶
API Reference¶
Full signatures for agent.goals(), the recording primitives, and their params structs.
Recall & Retrieval¶
recall/answer, item provenance, and dereferencing sources with agent.data().