Agent Learning
The agent-learning subsystem turns finished turns into durable, retrievable lessons — so the same agent (and its team, and the org) gets better over time without retraining the underlying model.
This page describes the shipped architecture: what runs in-engine, where each piece slots into the Turn Engine and Knowledge System, and the deliberate non-goals.
Provider-agnostic by design. Learning lives in the org/data layer, not in a model checkpoint. Any LLMProvider can back any role. Model fine-tuning is never required and is not part of the in-engine learning loop.
Why tools alone are not enough
Section titled “Why tools alone are not enough”A common misconception is that adding memory/skill tools is sufficient to make an agent “learn.” It is not. For a learning loop to be effective — i.e. for the LLM to reliably produce good lessons and invoke the right tool at the right moment — four layers must line up:
| Layer | What it does | Where Crewlet carries it |
|---|---|---|
| 1. Model training | Weights that know the memory/reflection protocol | Not required. Layers 2–4 do the work; any stock Claude/GPT works. |
| 2. Per-phase contract | Short, per-phase system-prompt rules that remind the LLM when to persist, reflect, recall | Plan/Review prompt builders in crewlet.agent.prompts — guidance blocks injected only when the matching tool is registered. See Prompt scaffolding. |
| 3. Tool descriptions | One-line when to use text on each tool — Crewlet pushes guardrails into descriptions, not prompts | Builtins (query_episodes, reflect_and_persist, refresh_memory, refine_skill, use_skill, mark_onboarded) have precise one-line descriptions. |
| 4. Deterministic harness | Post-turn code that runs reflection regardless of whether the LLM “remembers” to | ReflectEngine — the load-bearing piece. LLM cooperation is a bonus, not a dependency. |
Crewlet puts the weight on layers 2–4. Layer 1 is desirable but optional — effectiveness is not gated on any one vendor’s checkpoint.
Subsystems
Section titled “Subsystems”Six small components, each with a single responsibility, plus the orchestrator that wires them.
1. PersistDecider — post-turn personal memory
Section titled “1. PersistDecider — post-turn personal memory”Replaces “hope the LLM remembers to capture a durable fact” with a deterministic post-Review decision.
- Trigger: after
submit_reviewemitsdone, or after the engine terminates the turn asfailed(stall guard, max-iter exhaustion, unhandled exception, LLM unavailable).self_iterateis a mid-state; reinforcing it would teach the agent from incomplete work. - Decision: small auxiliary-model prompt answering what, if anything, should persist? Defaults to NOOP. The classifier picks a tier:
LONG— durable preference / fact (no TTL).SHORT— situational, with a TTL in days (sprint focus, vacation, delegation context).DOC— would be team-relevant; the decider does not write personal memory but logs the recommendation. Real cross-agent propagation goes through the team knowledge base, not the diary.NOOP— nothing worth persisting.
- Writing-style rule: persisted entries are declarative facts, not instructions.
"User prefers concise responses"✓ —"Always respond concisely"✗. Instructions drift out of date and get re-discovered as contradictions; facts compose cleanly. (Adopted verbatim from Hermes’s memory guidance.) - Effect: writes a row to the agent’s
agent_diaryviaAgentDiary.write— agent-scope only.
2. AgentDiary + reflect_and_persist — in-flight personal memory
Section titled “2. AgentDiary + reflect_and_persist — in-flight personal memory”The agent’s private observation log. Two kinds:
| Kind | TTL | Use |
|---|---|---|
diary_long | None | Durable preferences and facts (Stakeholder X prefers digests). |
diary_short | Set | Situational state (Sprint freeze runs through 2026-05-10, Opened PR-123 from sandbox run, awaiting review). Filtered out at read time once the TTL passes; physically deleted in batches by delete_expired. |
Two writers converge: the post-turn PersistDecider (above) and the in-flight reflect_and_persist LLM-facing builtin. Both go through AgentDiary.write, which embeds the content on write so the row is reachable by vector similarity later. The ## Personal memory prefetch and refresh_memory read the diary via hybrid candidate selection: AgentDiary.search_for_agent (vector top-K matches to the trigger) unioned with AgentDiary.list_for_agent (recency top-K), deduped by row id and capped at DEFAULT_CANDIDATE_POOL_LIMIT (100), then passed to an aux-LLM relevance filter that picks the final digest. The two halves serve different needs: vector search catches topical / semantic matches to the current trigger; recency catches broadly-applicable operational rules that may not be a topical match (e.g. “use semantic commit messages on every PR”). The aux filter judges from the merged pool.
Write-boundary hygiene. AgentDiary.write runs a cheap guard on every write: an exact-duplicate of a live row short-circuits to the existing row id rather than inserting a paraphrase the read-side filter would then have to wade through. Content is stored verbatim — never length-truncated, so the agent reads back exactly what was written; only the text handed to the embeddings provider is sliced (_MAX_EMBED_INPUT_CHARS) to stay within its token limit. The post-turn PersistDecider is additionally skipped when the turn already self-persisted in-flight (the planner called reflect_and_persist), so the two writers don’t double-write the same fact. Prompt-injection scanning at this boundary is a separate concern, deliberately not bundled into the hygiene pass — the guard is about write dedup, not content vetting.
The diary is read by:
- The Plan-phase
## Personal memoryprefetch block (seefetch_personal_memory_block). - The mid-turn
refresh_memorybuiltin, which re-runs the same diary query with an enriched context hint.
3. CounterpartyProfiler — entity modeling
Section titled “3. CounterpartyProfiler — entity modeling”Crewlet’s multi-party equivalent of Hermes’s “model of who you are.”
- Input: observed interactions per counterparty (colleague, stakeholder, external human) from Slack/Jira/A2A events. A coalesced trigger runs one observation pass per distinct sender (
merge_interactions_by_senderjoins a sender’s messages chronologically first) — a thread where one human sent four messages is one counterparty; a multi-human thread is genuinely several. - Output: one
CounterpartyProfilerow per(observer_handle, subject_handle | subject_external_id, subject_platform)— preferred communication style, past decisions, sensitivities, topics of interest. Stored in thecounterparty_profilestable (not the diary; not Confluence). - Scope: per-observer always — a fact one agent learns about Bob is private to that agent. Cross-agent propagation goes through humans + the team knowledge base, not auto-merging.
- Retrieval:
lookup_colleaguereturns the profile when present; the Plan phase auto-injects the trigger counterparties’ profiles into prompts when the trigger has identifiable senders (one block per distinct sender with a stored profile).
4. EpisodicMemory + query_episodes — search own past
Section titled “4. EpisodicMemory + query_episodes — search own past”Agents can search their own prior turns.
- Source: the
episodeshypertable — one row per completed turn (agent_handle,task_summary,plan_summary,tool_sequence,skills_used,review_outcome,started_at,ended_at,duration_ms,embedding). - Builtin:
query_episodes(query, limit, outcome_filter?)— vector similarity overtask_summary | plan_summaryconcat, scoped to the calling agent’s handle, available in Plan phase. - Auxiliary summarization: raw episode hits are passed through the role’s
llm_auxiliarymodel (a cheap one) before reaching the planner, keeping the planner’s context window small. Falls back to raw bullets when no aux model is configured. - Frozen-at-turn-start: the Plan-phase
## Similar prior workprefetch resolves once per turn and bakes the summary into the system prompt. Re-iteration (Review → Plan again) reuses the same prefix so the LLM provider’s prompt cache keeps working.
5. SkillSynthesizer — skill induction
Section titled “5. SkillSynthesizer — skill induction”Mines recurring successful trajectories and drafts a new procedural skill.
- Two trigger paths: single-turn (inline in
ReflectEnginewhen a turn used ≥min_tool_callstools and ended indone) and clustered (the periodicSkillClusteringSchedulergreedy-clusters recent successful turns by tool-sequence Jaccard and drafts from clusters of size ≥cluster_min_size). - Output: a row in
synthesized_skillskeyed by(agent_handle, name)— agent-scope only. The body is stored in the familiar SKILL.md Markdown shape, whichuse_skillreturns verbatim. - Cross-agent promotion: when ≥N siblings in the same
OrgUnitindependently converge on a similar pattern, a separatePromotionSynthesizerdistils the cluster into a draft page in the team knowledge base under the unit’sAuto-Drafted Skillsparent. The synthesizer builds a backend-neutral markdown draft and hands it to aPromotionPageWriter— the small consumer-owned seam (resolve_unit_container/missing_container_hint/create_draft_page) implemented per backend:ConfluencePromotionWriterposts rendered XHTML into the unit’sintegrations.confluence.space;PlanePromotionWriterposts into the unit’sintegrations.plane.project, ensuring the parent page exists. A unit without a configured container soft-skips with the writer’s remediation hint; a write failure returns nothing so the scheduler retries next tick. Because the scheduler re-clusters the same persisted rows every tick, cross-tick dedup is the writer’s job, per backend: Confluence rejects a repeat create with a 4xx on the duplicate title, while the Plane writer stampsexternal_id="draft:<name>"(the fork 409s on the duplicate pair) and returns the existing page instead of creating another — either way one converging cluster yields one draft, not one per tick. Draft titles carry the[Auto-draft]prefix (AUTO_DRAFT_TITLE_PREFIX), and the## Relevant knowledgesearch hides pages under theAuto-Drafted Skillsparent — so an unvetted draft never reaches other agents. A unit lead reviews and publishes by moving the page out of the parent; once published it’s a regular knowledge-base page, reachable through the query-time search. The engine carries no unit-scope skill rows of its own. Success publishes aSkillPromotedevent whosecontainer_keyfield carries the unit’s configured container (space key / project identifier) alongsidepage_id/page_title. - Collision guard: the synthesizer rejects names that already exist in the agent’s own
synthesized_skillstable. There’s no global skill registry to guard against — synthesized skills are per-agent, and shared procedures live in the team knowledge base rather than in an engine-side registry.
6. SkillRefiner + refine_skill — improve skills during use
Section titled “6. SkillRefiner + refine_skill — improve skills during use”When a synthesized skill was central to a successful turn, append an observed-in-practice bullet; when it contributed to a failed turn, append a counter-example.
- Auto path:
ReflectEnginedispatches the refiner after every turn whoseskills_usedis non-empty. The auxiliary model picks one observation (or NOOP); successful turns produceObserved in practice: …, failures produceCounter-example: …. - Manual path: the LLM-facing
refine_skillbuiltin lets the planner patch its own skills mid-Plan —action="append"adds a bullet,action="replace_body"rewrites the steps. Patch-on-encounter: if the plan finds a skill outdated, it patches immediately rather than waiting for a separate consolidation pass. - Versioning: every refinement archives the prior state to
synthesized_skill_versionsand bumps the live row’sversion. Rollback is a forward-step operation (the archived body becomes the body of a new version), so rewinding then un-rewinding works without losing history. History is bounded bymax_versions_kept(default 10) per skill. - Body cap:
max_body_chars(default 20 000) — refinements that would breach the cap are rejected so a runaway loop can’t blow up a skill body.
7. ReflectEngine — the orchestrator
Section titled “7. ReflectEngine — the orchestrator”The deterministic harness. Owns when reflection runs and coordinates the workers above.
- Hooks: subscribes to
TurnCompletedon the EventQueue. - Concurrency: acquires a
ConcurrencyControllerslot per role around dispatch and releases infinally, so reflection workers compete with live turns for execution slots. - Budget pre-flight: checks
BudgetManager— if either the org-wide or per-agent budget is exhausted, the whole pass short-circuits with areflection_skipped_budget_exhaustedlog. - Per-role gate:
Role.learning_enabled = Falseopts a noisy or sensitive role out of reflection without disabling the subsystem globally. - Idempotency: an LRU of recently-processed
turn_ids short-circuits duplicate deliveries; at-least-once queue semantics don’t cause duplicate writes. - Failure mode: every dispatch is best-effort. A failed worker logs and the next worker still runs; a failed reflect never fails the parent turn.
Prompt scaffolding
Section titled “Prompt scaffolding”Short, conditional guidance fragments are appended to the Plan-phase system prompt, injected only when the matching tool is registered for the role. This scaffolding is sourced from the Tool Skills registry — knowledge-base pages (Confluence or Plane) operators can edit at runtime — rather than being hardcoded in engine prose. The bundled examples/tool-skills/ files ship ready-made versions:
| Bundled skill | Trigger | What it teaches |
|---|---|---|
examples/tool-skills/reflect-and-persist.md | tool: reflect_and_persist | Persist declarative facts, not instructions to yourself. |
examples/tool-skills/refine-skill.md | tool: refine_skill | Patch a loaded skill when it goes stale; don’t wait to be asked. |
examples/tool-skills/retrieval-research.md | any_of of query_episodes / the plane MCP server / refresh_memory | The consolidated retrieval re-search rule — see below. |
examples/tool-skills/observed-directives.md | tool: slack_conversations_add_message | Share team-relevant directives via the agent’s broadcast surface. |
examples/tool-skills/getting-unstuck.md | any_of of colleague-surface tools (Slack post / the plane MCP server / a2a_ask) | Manager-handoff conventions — when stuck, mention manager on the surface where the problem lives. |
examples/tool-skills/channel-discovery.md | any_of of Slack discovery tools | How to find the right Slack channel via channels_list, and how to fall back when membership is missing. |
The retrieval-research skill carries the consolidated retrieval re-search block. The three relevance prefetches — ## Similar prior work, ## Relevant knowledge, ## Personal memory — are all derived from the triggering message as it stood at turn start, before any recon, so they share one rule: after recon has given the planner a richer query, re-query the corresponding tool — even when the initial block already had entries. Rather than repeat that rule in three near-identical blocks, the shared preamble states it once and one terse per-tool line (query_episodes / the knowledge backend’s page-search tools / refresh_memory) is appended for each re-query tool the role actually has. On a thin trigger the turn-start message genuinely is a bare pointer (the thin-trigger gate skips the prefetch entirely); on a substantive trigger it is the whole message but still pre-recon. Either way the guidance makes the assumption legible to the LLM so the re-query pattern does not rest on the model guessing.
Plus four always-on prefetch blocks (rendered when the data exists):
## Similar prior work— top 3 episode-search hits, summarised by the aux model.## Personal memory— diary entries selected via hybrid vector ∪ recency candidate selection, then filtered for relevance to the current task / trigger by the aux model.## Synthesized skills you've learned— names + descriptions of the agent’s own synthesized skills, loadable viause_skill.## Relevant knowledge— knowledge-base pages from a live query-time search: the aux LLM generates a short search query from the trigger, and theKnowledgeSearcherruns it scoped to the role’s accessible containers. See Relevant-knowledge prefetch below.
Plus the conditional prefetch:
## First-turn onboarding— rendered until the agent callsmark_onboarded. Lists the relevantOnboardingknowledge-base pages on the agent’s unit chain. Stored markers live inagent_onboarding_markers, keyed byagent_idand stamped with achain_hash; an org-chain change invalidates the marker so the hint re-fires for the new structure.
These blocks are layer 2 from the four-layer table above. The ReflectEngine (layer 4) runs regardless of whether the LLM follows them — the scaffolding is an optimization that lets well-behaved models cooperate, not a dependency.
Personal memory prefetch + refresh
Section titled “Personal memory prefetch + refresh”The Plan-phase ## Personal memory block runs fetch_personal_memory_block once at turn start: assembles a candidate pool of the agent’s diary rows, filters them by relevance to the trigger via the aux model, renders a digest. Bake the digest into the system prompt; do not re-query mid-turn.
Hybrid candidate selection
Section titled “Hybrid candidate selection”fetch_existing_memories is the candidate-pool helper that feeds the aux filter. Given a trigger query, it returns the union of two top-K reads against the agent’s diary:
- Vector top-K —
AgentDiary.search_for_agent(query=trigger)runs cosine similarity against the diary’s embedding column, scoped to the agent’s id. This catches topical / semantic matches to the trigger. - Recency top-K —
AgentDiary.list_for_agent(limit=N)reads the most-recent rows in insert order, again scoped to the agent’s id. This catches broadly-applicable operational rules that may not be a topical match to this particular trigger — “use semantic commit messages on every PR,” “always tag the security channel before merging auth changes” — which the vector half would miss when the trigger is unrelated to the rule’s topic but the rule still applies.
The two sets are deduped by row id and capped at DEFAULT_CANDIDATE_POOL_LIMIT (100). The aux-LLM relevance filter then judges from this merged pool — same filter as before, just a better-recall candidate pool. The hybrid is not pure vector (which would miss the broadly-applicable rules) and not pure recency (which falls off for long-lived agents with >100 LONG entries — old-but-relevant rows would drop off the window and never reach the filter).
PersistDecider’s write-side dedup keeps calling fetch_existing_memories without a query — that path falls back to pure recency, which is the correct shape for the “is this paraphrase already in the diary?” check.
Failure modes
Section titled “Failure modes”The prefetch filters against the salient inbound message — the raw message, not the notification builder’s enriched task description (see Salient-body sourcing). That leaves two failure modes:
- Context-thin triggers (“yes”, “+1”, a thread reply with little semantic content) — the salient message itself is thin, so the filter has nothing to match on and the block ends up empty. When that happens and the agent has memory rows, the block renders an
EMPTY_FILTER_HINTline nudging the planner to refresh after recon. - Richer triggers can produce a non-empty block, but the entries the trigger-time filter chose may not be the most relevant once the planner has read the thread / fetched the ticket / queried knowledge and learned what the conversation is actually about.
The refresh_memory(context_hint=…) builtin fixes both. The refresh_memory line of the bundled retrieval-research Tool Skill (examples/tool-skills/retrieval-research.md) tells the planner to call refresh after any tool call that materially changed its understanding of the conversation — even when the initial block already had entries, not only as an escape hatch from the empty case. The tool re-runs the filter with the planner’s enriched context_hint appended to the original task and returns the freshly-rendered digest as the tool result. Bounded by:
- Per-turn cap —
learning.personal_memory.max_refreshes_per_turn(default 3). Distinct hints exceeding the cap return an error so the planner stops trying instead of silently no-op’ing. - Idempotency cache — repeat calls with the same hint within one turn (case- and whitespace-normalised) return the cached output without firing a fresh LLM call.
- Per-turn isolation — state keyed by
turn_id, bounded by an LRU on the closure; state from one turn never leaks into another. - Frozen-prefix-cache safe — refresh output lands as a tool-result message, not a system-prompt rewrite. The LLM provider’s prompt cache stays valid across iterations.
Relevant-knowledge prefetch
Section titled “Relevant-knowledge prefetch”The Plan-phase ## Relevant knowledge block surfaces team-published documents — playbooks, runbooks, ADRs, conventions, design docs, anything in the agent’s accessible knowledge-base containers — without forcing the planner to discover them by guessing names against use_skill or by remembering to call the knowledge-search tool first. It runs a live knowledge-base search once per turn through the KnowledgeSearcher seam (Confluence CQL or Plane page search — one backend per org); the Personal memory prefetch is the closest sibling in spirit, though that one reads the private diary via hybrid vector ∪ recency candidate selection filtered by an aux-LLM relevance pass.
Why “knowledge” and not “skills”
Section titled “Why “knowledge” and not “skills””An alternative design would carve out a special “team-skill” label so operators could mark certain pages as procedures meant for agents. That reintroduces an operator-curated/synthesized skill split — two parallel skill surfaces to maintain — which the project deliberately avoids.
The shipped design takes the opposite stance: a knowledge-base page is a knowledge-base page. The search runs against the agent’s accessible containers and the backend’s own ranking decides which pages come back (Confluence: relevance; Plane: recency); there is no engine-side “skill” label or parallel surface to maintain.
Source: query-time knowledge-base search
Section titled “Source: query-time knowledge-base search”For each Plan turn:
- The searcher gate runs:
searcher.can_search(role, org)— a cheap, no-I/O check that a search could return anything (the role has accessible containers, or its own backend credentials for an unscoped search). When it says no, the aux-LLM query-generation call is skipped entirely. - The role’s auxiliary model (
role.llm_auxiliary) turns the task description into a short plain-text keyword query (the user prompt endsKnowledge-base search query:). Scope is not the aux model’s job — the searcher derives it internally from the org-wideknowledge.*list viaaccessible_spaces/accessible_projects. There is no per-unit/role union: a unit’sintegrations.confluence.space/integrations.plane.projectis integration identity (webhook routing + write home), not read scope. - The searcher runs the query as the agent’s own backend user (per-agent token from
mcp_env.atlassian/mcp_env.plane, falling back to the org-level token) — on Confluence as a CQLtext ~ "..."clause narrowed byspace IN (...), on Plane sent verbatim to the fork’s tokenised page search narrowed by resolved project UUIDs. The backend enforces page permissions natively, so restricted pages the agent cannot see never appear; unreviewed auto-drafts are excluded via the defaultexclude_ancestors=["Auto-Drafted Skills"].
Loading full bodies
Section titled “Loading full bodies”The bullets render title + snippet — enough for the planner to decide which pages to open. To pull a full body or run a fresh search, the planner uses the backend’s MCP tools — on Confluence confluence_get_page / confluence_search, on Plane the plane server’s page read/search tools. The block prose describes the capability, never a hardcoded tool name.
Hardening
Section titled “Hardening”- Show-nothing on search unavailability / failure. When the backend is unreachable or query generation fails, the block renders nothing rather than erroring the turn —
search()is best-effort by protocol contract. EMPTY_FILTER_HINTrendered when the block would otherwise go silently empty — either the thin-trigger gate skipped the search, or the search ran and returned nothing. Mirrorspersonal_memory’s hint; points the planner at the knowledge-search tool as the mid-turn escape hatch.- Frozen at turn start. The block is part of the system-prompt prefix, so re-iteration (Review → Plan) reuses the same prefix and the LLM provider’s prompt cache stays valid.
- Once per turn. The query is generated and the search runs once; the result is reused across phases.
Post-Plan re-fetch (thin triggers)
Section titled “Post-Plan re-fetch (thin triggers)”On a thin-trigger turn the Plan-time ## Relevant knowledge prefetch is gated off — generating a search query from a bare pointer is noise. That leaves a gap: the planner does its recon inside the Plan phase, but the Execute phase that follows would still run blind, with no relevant-knowledge block at all.
fetch_post_plan_relevant_knowledge (agent/plan.py) closes it. After Plan submits, the TurnEngine re-runs the relevant-knowledge search — but keyed on plan.summary(), not the task description. On a thin trigger the original task is webhook boilerplate; the plan summary is the recon-informed, task-shaped query the gate was waiting for. The re-fetch uses the query_override parameter of the relevant-knowledge wrapper, which forces the thin-trigger gate off (the override is a real query) and re-points query generation at it. The rendered block is injected into the Execute system prompt as its own ## Relevant knowledge section.
It runs only when all of: the trigger required recon (otherwise the Plan-time prefetch already saw a real trigger — no gap), the plan decision is plan or direct (skip never reaches Execute), and the plan summary is non-empty. It runs once per turn iteration — a self_iterate round produces a fresh plan summary, so the re-fetch reflects the corrected plan.
This is the push half of the thin-trigger story: the retrieval re-search guidance is the pull path (the planner chooses to call the knowledge-search tool mid-Plan), and the post-Plan re-fetch is the non-discretionary push into Execute that fires whether or not the planner pulled. Boundary: it enriches only the Execute phase that follows Plan — it does not retroactively cover tool calls the planner made inside the Plan phase.
Telemetry
Section titled “Telemetry”PlanPrefetchSummary.relevant_knowledge_hit / relevant_knowledge_bytes / relevant_knowledge_selection_count are recorded alongside the other prefetch blocks. The selection count distinguishes the two hit=True paths: a non-zero count means real pages were rendered; zero with hit=True means the EMPTY_FILTER_HINT was rendered — the thin-trigger gate skipped the search, or the search ran and returned nothing. Operators investigating low effectiveness pivot on this field to tell “no signal” from “hint nudge only.”
The post-Plan re-fetch emits its own RelevantKnowledgeRefetched event whenever it takes its active path (thin trigger + plan / direct + non-empty summary), carrying iteration, plan_decision, block_bytes, and selection_count. The overwhelmingly common non-thin-trigger turn emits nothing. The event lets an operator correlate a gated Plan prefetch with the block Execute actually received.
A block stuck at 0% hit rate over a representative window is almost always one of:
- No
knowledge.confluence_spaces/knowledge.plane_projectsconfigured and the agent has no per-agent backend credentials, so it can’t search unscoped (a credential-less / fallback-token agent with no containers searches nothing —can_searchgates the whole prefetch off). - Neither
confluencenor an enabledintegrations.planeconfigured, so no searcher is wired — or no pages in the accessible containers match. On Plane, also check project membership: the search is membership-scoped, so a seat that isn’t a member of the scoped projects silently gets nothing (see Plane § Knowledge scope). - Aux LLM unavailable (
llm_auxiliarynot configured and the role’s primaryllmdoesn’t resolve as an aux provider), so query generation cannot run.
Thin-trigger gate
Section titled “Thin-trigger gate”All three relevance-driven Plan-phase prefetches — ## Personal memory, ## Relevant knowledge, and ## Similar prior work (episode recall) — run an aux-LLM call against the bare trigger at turn start, before the planner has done any recon. For a self-contained trigger (a full task assignment, a detailed issue body) that’s high-value: the planner gets relevant memory / docs / episodes baked into the system prompt for free.
But for an event-driven turn the trigger is a pointer, not the context. A Jira webhook says “POC-518 got a comment”; a Slack thread reply says “+1”. The real context only exists after the planner fetches the issue / reads the thread. Running the aux filter against the bare pointer is near-guaranteed low-value — it has nothing substantive to match against — and we’d also spend prompt space rendering “nothing matched, go look later”. So on the common webhook turn we’d pay twice (a wasted aux call + prompt clutter) for a result the planner has to redo via tools anyway.
The gate skips the aux call when the trigger is a pointer. It is pure logic — no LLM call: the decision is read from notification metadata (issue_key / thread_ts / event_type), which is exactly why it’s cheap enough to gate on.
| Stage | Carries the signal |
|---|---|
| Notification builder | NotificationPrompt.requires_recon(notification) — True when the builder emitted a “go fetch the real thing” directive. Jira / Confluence page / Plane work-item + page events (## Get Full Context), GitHub review_requested (“read the diff”), Slack thread replies (read-the-thread). The generic builder returns False — its body is the message. |
NotificationService | notification_requires_recon(notification) → ExternalNotification.context_requires_recon. |
InboundInteraction | list_from_trigger_event reads the flag into InboundInteraction.requires_recon — the one normalized, platform-agnostic property workers may branch on (it is not an event.type check). A coalesced trigger yields one interaction per constituent message, all carrying the event-level merged flag; interactions_require_recon(interactions) is the whole-trigger predicate. A2A and internal TaskAssigned triggers carry their own context → always False. |
| Prefetch | All three relevance prefetches read it. fetch_personal_memory_block / fetch_relevant_knowledge_block take trigger_requires_recon; _fetch_episode_recall_block reads interactions_require_recon directly. When set: skip the aux call (for personal memory the relevance filter, for relevant knowledge the query generation and live knowledge-base search, for episode recall the vector query). All three then render a gate-path hint so the block stays visible and self-explanatory rather than vanishing — EMPTY_FILTER_HINT for personal memory and relevant knowledge, _EMPTY_RECALL_HINT for episode recall — and the matching per-tool line in the retrieval re-search guidance carries the same nudge. |
The signal lives at the notification builder because the builder decides whether to emit a recon directive — classifying from event.type downstream would duplicate that decision and let the two drift. A raw token-count heuristic doesn’t work here: a webhook task_description is long (title + event metadata + multi-step “How to Handle This” boilerplate) but thin on substance — length would wrongly classify it as rich.
Personal memory still does its cheap diary recency list on a thin trigger (a DB read, no LLM) so it can render the hint only when the agent actually has memory rows to refresh — the vector half of the hybrid would key on a bare pointer that has nothing substantive to match, so it’s skipped alongside the aux filter. Relevant knowledge skips the query generation and live knowledge-base search entirely — it only needs can_search to confirm a search could return anything (so the search-tool nudge is actionable) before rendering the hint. Episode recall skips the vector query outright and renders its hint unconditionally: unlike a diary list or an accessible-spaces check, the only way to know whether an agent has matching past episodes is the vector query the gate exists to skip — so the hint is phrased conditionally (“if this task resembles something you have done before…”) to read correctly even for an agent with no episodes.
Observability. PlanPrefetchSummary.trigger_requires_recon records the gate decision once per turn. Without it, a gated prefetch and a filter that ran-and-found-nothing look identical in telemetry (both *_hit=False / selection_count=0); with it, an operator seeing an empty ## Relevant knowledge block can tell the prefetch was gated (the trigger was a pointer) rather than broken. The event’s summary surfaces it in the trace view ("… plan prefetch: N/6 hits (thin trigger — filters gated)").
This makes the prefetch honest about its role: it’s an optimization for rich triggers, and for event-driven turns the tool-call path is the primary retrieval path — re-query-after-recon is the expected pattern, not a fallback. That re-query happens two ways: the planner pulls mid-Plan via refresh_memory / the knowledge backend’s search tools / query_episodes (guided by the retrieval re-search guidance), and for relevant knowledge the TurnEngine also pushes — the post-Plan re-fetch re-runs the search keyed on the plan summary and injects the result into the Execute prompt, so Execute is covered whether or not the planner pulled.
Salient-body sourcing
Section titled “Salient-body sourcing”The relevance prefetches, the counterparty profiler, the PersistDecider, and refresh_memory all reason about what the sender said. None of them want the notification builder’s scaffolding.
build_notification_prompt produces the enriched body — for a Slack message, ~1.5k chars of ## Triage instructions front-loaded before the actual message. That enriched body becomes the planner’s task_description (the planner needs the triage contract). But a relevance filter keyed on a task[:N] prefix of it never reaches the message: it filters against boilerplate that is byte-identical on every Slack turn.
So the raw message rides separately. ExternalNotification.salient_body carries InboundNotification.body verbatim — the message, no scaffolding — alongside the enriched body. InboundInteraction.body is sourced from it (falling back to the enriched body for events that carry no salient_body); a coalesced trigger sources one interaction body per constituent message. salient_task_text(interactions, fallback) (learning/interaction.py) is the single chooser: a single interaction body renders verbatim, multiple bodies join chronologically with sender attribution (Alice: …) and the joined text is clipped to the same 4,000-char bound a single body always had (embedding queries and filter prompts never receive max_batch × 4000 chars), and the turn’s task_description is the fallback — internal TaskAssigned triggers have no notification scaffolding, so the fallback is already clean.
Every relevance surface routes through it:
| Surface | Reads |
|---|---|
## Personal memory prefetch | salient_task_text → aux filter prompt |
## Relevant knowledge prefetch | salient_task_text → aux-LLM query generation + knowledge-base search |
## Similar prior work (episode recall) | salient_task_text → vector query |
refresh_memory | salient_task_text as the base task the context_hint is appended to |
| Counterparty profiler / PersistDecider | InboundInteraction.body directly |
Without this, a stored memory that perfectly answered a question went unused: the filter only ever saw the triage boilerplate, so it could not see the question.
Episode lifecycle
Section titled “Episode lifecycle”The episodes hypertable is the raw substrate of agent learning. Without lifecycle management it grows forever. The EpisodeLifecycleWorker drains it on a write-triggered, demand-proportional cadence — no daily cron, no caller-path latency.
Trigger: write-side, threshold-based
Section titled “Trigger: write-side, threshold-based”Every EpisodeStore.write increments a per-agent counter; every Nth write (default 10) the store runs a cheap count(*) and, if the agent’s raw-episode total has crossed max_raw_episodes_per_agent (default 500), publishes a CompactionRequested event. The lifecycle worker is the subscriber. Idle agents fire nothing; busy agents fire often. A per-agent semaphore in the worker dedups concurrent requests.
One worker, four actions
Section titled “One worker, four actions”For each CompactionRequested event the worker runs the full lifecycle pass for that agent:
- Drop non-terminal episodes older than
non_terminal_max_age_days(default 14).self_iterateis a mid-state — the reflect engine’s terminal-outcome gate already excludes it from skill synthesis, and it only feedsquery_episodesrecall as noise. Cheap SQL DELETE; no LLM. - Drop skill-consolidated episodes older than
consolidated_grace_days(default 30). When the synthesizer drafts a skill from a cluster of episodes it stampsconsolidated_into_skill_idon each source row; the lifecycle worker drops them after grace because the skill itself now carries the learning forward. The grace gives operators a chance to audit / detect bad consolidations before the source disappears. - Compact the rest — the centerpiece. Pulls remaining raw episodes older than
compaction_min_age_days(default 30), greedy-clusters them by tool-sequence Jaccard, and for each cluster of size ≥compaction_min_cluster_size(default 3) calls the role’sllm_auxiliaryto summarise into aCompactedEpisodeshape (common_task_pattern,common_outcome,success_rate,subjects_involved,notable_patterns). Writes onekind='compacted'row, deletes the cluster’s originals (except 2-3 exemplars retained as raw rows for drill-down, referenced by the new compacted row’sexemplar_turn_ids). - Optional: evict ancient compacted entries older than
compacted_max_age_days(default 0 = disabled). Hard long-tail storage cap for orgs that need years-out limits; off by default since compacted summaries are 10-100× smaller than the raw rows they replaced.
Two physical row shapes share the same table
Section titled “Two physical row shapes share the same table”After the migration episodes rows distinguish on kind:
| Field | kind='raw' | kind='compacted' |
|---|---|---|
count | always 1 | N original episodes collapsed |
task_summary / plan_summary / tool_sequence / review_outcome | per-turn detail | the cluster’s common values |
started_at / ended_at | one turn’s timestamps | the cluster’s window |
common_task_pattern / common_outcome / success_rate / subjects_involved / notable_patterns | unused | LLM-summarised aggregate |
exemplar_turn_ids | empty | 2-3 raw rows kept as drill-down anchors |
consolidated_into_skill_id | set when a skill drafted from this row | always NULL |
Vector similarity returns both kinds in one query. Callers branch on kind at render time:
query_episodesbuiltin — kinds=both; renders raw entries as single past turns, compacted entries as[pattern, observed N×]aggregates.## Similar prior workPlan-prompt block — kinds=both; the auxiliarysummarize_episodesstep has a kind-aware prompt that emits the right bullet shape per row.SkillSynthesizer— kinds=['raw']only. Compacted aggregates are too coarse to draft a clean skill body from.SkillRefiner— same: raw-only.
What this protects
Section titled “What this protects”- Storage growth — bounded by
max_raw_episodes_per_agentfor raw rows; compacted rows are ~10-100× smaller per unit of original work. - Recall pollution — non-terminal noise drops fast; old patterns become aggregate summaries instead of crowding similarity hits.
- Learning drift — when a skill captures a workflow, the source episodes get out of the planner’s view (after grace), so the agent stops being shown stale per-turn detail of work the skill now represents abstractly.
- Long-tail signal preservation — routine work that never qualifies as a skill (most agent turns) survives as a compacted aggregate rather than getting dropped wholesale. The planner can still answer “you’ve done this kind of work N times” via the compacted entry.
What does NOT happen
Section titled “What does NOT happen”- Reads never trigger compaction — only writes can fire the trigger. Read paths pay no latency cost for lifecycle work.
- Compaction never feeds skill synthesis — the consolidation hierarchy is one-directional: raw → skill, raw → compacted. A compacted entry doesn’t get re-promoted to a skill; if the same pattern recurs after compaction, the new raw episodes form a fresh cluster the synthesizer can pick up.
- No daily cron, no idle work — agents that don’t accumulate episodes do no lifecycle work at all.
Telemetry harness
Section titled “Telemetry harness”The learning loop produces durable artefacts (synthesized skills, diary entries, counterparty profiles, episodes) and the surfaces that read them. Without per-surface measurement an operator cannot answer two basic questions:
- Are skills being used? Berlot-Attwell et al. (2024) showed that in some library-learning systems the apparent gain from skill induction comes from extra LLM sampling rather than skill reuse. Crewlet’s induction pipeline does real work; whether the resulting skills earn their keep is an empirical question that requires telemetry.
- Are the Plan-phase prefetches actually firing? A block stuck at 0% hit rate (e.g.
episode_recallreturning empty for every turn) is almost always a configuration / data problem, not a turn problem — but only visible if hit / miss is recorded.
The harness lives in:
| Surface | What’s tracked | Where it lands |
|---|---|---|
synthesized_skills.use_count / last_used_at | Per-skill load count + most-recent-load timestamp | Bumped by SynthesizedSkillStore.mark_used, called from _use_skill after a successful resolution. |
SkillUsed event | One per use_skill(name) resolution | Published on crewlet.events.skill_used; correlated to the host turn via trace_id / span_id. |
SkillSynthesized / SkillRefined / SkillPromoted events | Lifecycle markers — induction, refinement, cross-agent promotion | Published on crewlet.events.skill_*; the dashboard groups them by trace. |
PlanPrefetchSummary event | One per turn after the Plan-phase prefetches resolve, recording per-block hit (bool) + bytes (rendered size) + the trigger_requires_recon gate decision. | Published on crewlet.events.plan_prefetch_summary once per turn. |
RelevantKnowledgeRefetched event | Emitted when the post-Plan re-fetch takes its active path (thin trigger + plan / direct), recording iteration / plan_decision / block_bytes / selection_count. | Published on crewlet.events.relevant_knowledge_refetched; nothing on non-thin-trigger turns. |
PersistDeciderCompleted.classification / ttl_until | Tier label (LONG / SHORT / DOC / NOOP) + TTL on SHORT writes | Existing event extended so dashboards can plot the per-agent tier distribution. |
learning_health SQL view | Per-agent rollup: total_skills, skills_used_at_least_once, total_skill_uses, most_recent_skill_use, avg_uses_per_skill, avg_skill_age_days | Created by 005_skill_use_telemetry.sql; query directly from psql / a dashboard. |
Berlot-Attwell threshold
Section titled “Berlot-Attwell threshold”The single load-bearing metric is avg_uses_per_skill from learning_health. The literature’s working threshold:
avg_uses_per_skill < 0.1 → the library isn't doing what it claims; investigate retrieval, granularity, or whether the gain is just from extra samplingA new agent will sit at zero until it has been alive long enough to retrieve. Combine with avg_skill_age_days to discount young rows.
Best-effort rule
Section titled “Best-effort rule”Every telemetry write — mark_used, SkillUsed publish, PlanPrefetchSummary publish — is best-effort: a failure is logged once and swallowed so the host path (skill load, turn) is never broken by measurement. Test mode (no event queue / no DB) is a silent no-op.
Integration points
Section titled “Integration points”| Touchpoint | Role |
|---|---|
crewlet.agent.turn (TurnEngine) | Emits TurnCompleted with plan, tool trace, review outcome, skills used. |
crewlet.agent.prompts | Plan-phase prompt builders inject conditional guidance blocks gated on tool availability. |
crewlet.knowledge | The KnowledgeSearcher seam (protocol) with its two backends — ConfluenceSearcher (CQL) and PlaneSearcher (fork page search) — backing the ## Relevant knowledge prefetch; accessibility scopes it by space / project. See Knowledge System. |
crewlet.tools (registry) | Builtins: query_episodes, reflect_and_persist, refresh_memory, refine_skill, use_skill, mark_onboarded. |
crewlet.events | TurnCompleted, SkillSynthesized, SkillRefined, SkillPromoted, SkillUsed, PersistDeciderCompleted, CounterpartyProfileUpdated, PlanPrefetchSummary, RelevantKnowledgeRefetched, CompactionRequested. |
crewlet.timescaledb | Underpins the episodes hypertable + the dashboard event store. |
crewlet.learning/ | ReflectEngine, SkillSynthesizer, SkillRefiner, PromotionSynthesizer, SkillClusteringScheduler, EpisodeLifecycleWorker, PersistDecider, CounterpartyProfiler, AgentDiary, OnboardingMarkerStore, relevant_knowledge.fetch_relevant_knowledge_block. |
crewlet.config | learning: block — per-role enable flag, reflection budget, promotion thresholds, lifecycle knobs. See Configuration. |
crewlet.api | GET /agents/{id}/memory aggregates personal memories, episodes, counterparty profiles, and synthesized skills for the dashboard’s per-agent memory view. See API endpoints. |
Data model summary
Section titled “Data model summary”| Table | What it holds | Keyed by |
|---|---|---|
episodes (hypertable) | One row per completed turn (raw) or per cluster (compacted) | id; partitioned by started_at |
agent_diary (pgvector) | The agent’s private observation log; rows carry an embedding for the vector half of the ## Personal memory prefetch’s hybrid candidate selection | id; indexed by agent_id, kind; HNSW on embedding |
synthesized_skills | Auto-drafted skills, agent-scope | id; unique on (agent_handle, name) |
synthesized_skill_versions | Refinement history | id; references skill_id |
counterparty_profiles | One row per (observer, subject, platform) | composite |
agent_onboarding_markers | mark_onboarded bookkeeping | agent_id (PK) |
Shared knowledge has no table — the knowledge base (Confluence or Plane) is searched live (see Knowledge System).
Deliberate non-goals
Section titled “Deliberate non-goals”- Single-user persona model. Crewlet is multi-party;
CounterpartyProfileis per-identity and observer-scoped. - Model-level fine-tuning as a core feature. Optional, downstream of a stable trajectory dataset. No role is required to use a learning-aware model.
- Cross-org knowledge leakage. Synthesized skills are agent-scope only; cross-agent promotion lands as a knowledge-base draft for human review, not as an engine-side row.
- Black-box self-modification. Every synthesized skill edit is versioned and rollback-able. Counterparty profiles are written through a single observer, never auto-merged.
- Auto-promotion of casual remarks to team rules. A directive issued in Slack to one agent reaches another only when (a) a human or authorized agent updates the relevant knowledge-base page, (b) the receiving agent broadcasts to the team, or (c) someone with structural authority decides to formalise. The system does not auto-promote personal
CounterpartyProfileobservations to unit-shared knowledge. - A monolithic “learning agent.” Six small, independently testable components beat a single reflective super-loop.
Prior art: Hermes Agent
Section titled “Prior art: Hermes Agent”The learning subsystem was designed with Nous Research’s Hermes Agent as a reference point — reimplemented rather than taken as a dependency. Hermes is a vertically-integrated single-user CLI agent, not a library — its memory manager, skill tools, and session search are threaded through a 600k-line monolith with assumptions (home-directory storage, single user, single agent, no hierarchy) that are incompatible with Crewlet’s org model.
That said, several Hermes design choices are directly useful and adopted above:
| Hermes pattern | Adopted where |
|---|---|
| Conditional prompt-guidance blocks injected only when the matching tool is registered | Prompt scaffolding |
| “Declarative facts, not instructions to yourself” memory-writing rule | PersistDecider writing-style rule |
| “Patch skills on encounter — don’t wait to be asked” | SkillRefiner patch-on-encounter norm |
| 5-tool-call default threshold for treating a turn as skill-worthy | SkillSynthesizer default trigger |
| Cheap auxiliary model for summarizing session/episode-search hits | query_episodes + ## Similar prior work prefetch |
| Frozen memory snapshot at session start for prefix-cache stability | Plan-phase prefetches frozen at turn start |
Pluggable MemoryProvider interface (mem0, honcho, supermemory, …) | Validates the agent_diary store shape |
Explicitly rejected:
- Monolithic CLI coupling — Hermes’s learning loop is threaded through
run_agent.py; ours sits behind theEventQueueas its own package. - LLM-nudge-only triggers — Hermes’s pipeline fires only if the model invokes the tool. Ours pairs nudges with a deterministic
ReflectEngine. - Single-user
USER.mdpersona — replaced by multi-partyCounterpartyProfilekeyed by(observer, subject, platform). - Home-dir file storage — replaced by Postgres + pgvector tables.
- Unversioned skill overwrites — Crewlet keeps prior revisions for rollback.
- No model fine-tuning requirement — notably, Hermes itself also runs on stock models; Crewlet’s in-engine learning never touches weights.
Generated from crewlet/crewlet v0.1.0 at b40ea18.