Memory, Workflows, and SkillDAG
Three subsystems surround the core query loop: the tiered memory subsystem (model_harness/memory/) stores and recalls facts across sessions, the workflow scheduler (model_harness/workflow/) runs multi-step DAG workflows on triggers, and the SkillDAG integration (model_harness/skilldag/) enriches prompts from — and mines skills into — an external skill-graph sidecar. All three are fail-open: none of them may break a query.
flowchart LR
Q[User prompt] --> R[MemoryManager.recall]
R --> STM[STM ring buffer]
R --> MTM[MTM SQLite + embeddings]
R --> LTM[LTM JSON files + Chroma index]
STM --> MB[Relevant memory block]
MTM --> MB
LTM --> MB
Q --> E[enrich_with_skilldag]
E -->|GET search| SD[SkillDAG sidecar]
SD --> SB[SkillDAG context block]
MB --> SYS[system prompt sent to model]
SB --> SYS
SYS --> X[finished exchange]
X -->|fire-and-forget| MINE[POST mine-skills]
X -->|MemoryAwareAgent only| W[MemoryManager.remember]
Memory subsystem
Tiers
MemoryManager (model_harness/memory/manager.py:41) orchestrates three tiers, modeled on human cognition (model_harness/memory/dtos.py:24):
| Tier | Class | Storage | Eviction |
|---|---|---|---|
| STM | STMStore (stm.py:30) |
in-memory OrderedDict ring buffer |
oldest evicted past 100 items / 10 000 estimated tokens (stm.py:38) |
| MTM | MTMStore (mtm.py:38) |
SQLite at <DATA_DIR>/memory/mtm.db (WAL + busy timeout, mtm.py:69) |
none built in |
| LTM | LTMStore (ltm.py:51) |
one JSON file per item under <DATA_DIR>/memory/ltm, plus a derived ChromaDB vector index at ltm/chroma when chromadb is importable (ltm.py:134) |
consolidate() prunes/merges |
MemoryItem (dtos.py:41) carries content, importance (0–1), optional embedding, agent/session/user ids, and an access count. DATA_DIR is resolved in factory.py:208 (arg > DATA_DIR/HARNESS_DATA_DIR env > config default data); _wire_memory (factory.py:313) builds the stores, shares one EmbeddingProvider between MTM and LTM, and assigns the MemorySystems facade (system.py:20) to harness.memory_systems.
Write path
MemoryManager.remember() (manager.py:81) routes by tier argument; the default AUTO writes STM always, MTM always, and LTM only when importance >= promotion_threshold (0.7, manager.py:57). Cascade promotion also runs on access: record_access() increments the MTM access count and promotes MTM→LTM at 5 accesses (manager.py:359). LTMStore.write() additionally summarizes content over 5 000 tokens through the harness model when one is wired (ltm.py:270), and MemoryAwareAgent (memory_agent.py:23) wraps any agent to auto-store each task+result with a heuristic importance score after every run (memory_agent.py:87).
Recall path
MemoryManager.recall() (manager.py:161) searches all three tiers in parallel via asyncio.gather(..., return_exceptions=True), then merges by cross-tier rerank (2026-08-15): the stores' semantic paths attach a transient MemoryItem.score (cosine similarity), hits rank by raw score, keyword-tier hits take a mid-band default (0.55), ties break by tier preference STM > MTM > LTM (AC-4.17), and every tier contributes candidates before the top_k cut — so a strongly relevant LTM hit is no longer masked by weak STM/MTM matches. A tier that raises is logged and skipped, not fatal (manager.py:211). Per tier:
- STM: case-insensitive substring match, most-recent-first (
stm.py:80). - MTM: SQL pre-filter (agent/session/user/importance/time), then cosine similarity in Python over at most the 500 most recent embedded rows (
mtm.py:198); falls back toLIKEsearch on any embedding error. - LTM: Chroma ANN query when the index is active, else an O(n) in-memory cosine scan (
ltm.py:325); falls back to keyword matching. Hits belowDEFAULT_MIN_SIMILARITY = 0.30(dtos.py:144) are dropped, so unrelated queries validly return nothing.
Embeddings engine
EmbeddingProvider (embeddings.py:219) tries engines in order and caches failures so a dead engine is not retried per call:
- local — sentence-transformers
all-MiniLM-L6-v2, registered only if the package is importable (optional dependency,embeddings.py:255). - remote — any OpenAI-compatible
/embeddingsendpoint, configured viaHarnessConfig.embedding_*/OPENAI_API_KEY(embeddings.py:266). - fallback — deterministic hash-based pseudo-embeddings that always work (
embeddings.py:176).
Embedding cold-start is warmed in a background daemon thread at harness init (factory._wire_memory → EmbeddingProvider.warm_up(), 2026-08-15): the first embed in a process otherwise pays the torch import + model load (~90s worst case), which used to stall the first query after every restart. The local engine loads offline-first (local_files_only=True) when the model is cached, loads in the executor (never on the event loop), and every prompt section build is capped at 10s (fail-open skip) — a cold engine can no longer freeze streaming. When only the fallback is active, EmbeddingProvider.degraded is True and semantic recall is keyword-grade; GET /api/memory/stats surfaces embedding_degraded / embedding_provider (routes_memory.py:41, manager.py:467).
Fail-open design
Memory must never break a query. Concretely: recall exceptions per tier are swallowed (manager.py:211); a failed embed stores the item without a vector instead of a zero-vector (ltm.py:296); Chroma failures fall back to the in-memory index (ltm.py:162); the direct-chat and agent recall hooks are wrapped in bare try/except (routes_query.py:670, agents/base.py:1094); and LTMStore._load_from_disk skips corrupt JSON files with a warning (ltm.py:240).
Where memory enters a query
- Direct chat —
_direct_stream_asyncrecallstop_k=5viaharness.memory_systems.retrieve(query=prompt)and prepends a=== Relevant memory ===block to the system prompt (routes_query.py:658). Gated byHarnessConfig.direct_chat_memory_recall(default ON), overridable per request with thememory_recallbody flag (routes_query.py:246). - Agent runs —
_recall_relevant_memories(agents/base.py:1073) runs once per run when the system message is built, token-capped bymemory_recall_max_tokens; arecalled N memoriesnotice is surfaced on the step. - Memory tab / API —
GET /api/memory(recall),POST /api/memory/remember(write, default importance 0.6) inroutes_memory.py. - Memory proposals — an explicit "remember …" intent in direct chat is intercepted by
extract_memory_proposaland short-circuits the model call, emitting amemory_proposalSSE event so the user confirms before anything is written (routes_query.py:605).
Workflow scheduler
Definitions
A workflow is a WorkflowDefinition5 (workflow/dtos.py:214): id, ordered WFStep list, error_handling (fail/continue/retry), and max_concurrency. Each WFStep (dtos.py:71) has a type — agent, tool, condition, parallel, human_gate, sub_workflow, transform, sleep, skill — plus depends_on, max_retries, Jinja2-templated args, and an output_mapping that copies step outputs into workflow variables. skill steps execute through the Phase 6 SkillLibrary (step_handlers.py:334).
Execution
WorkflowExecutor.execute() (executor.py:88) builds the DAG with DAGBuilder (dag.py:32) — duplicate step ids, missing dependencies, and cycles all raise DAGError — then Kahn's topological sort yields levels; steps within one level run in parallel via asyncio.gather (executor.py:213). Failures retry with exponential backoff (1s, 2s, 4s…, executor.py:301), then follow the workflow's error_handling. Human-gate steps pause for POST /api/workflows/approve (routes_workflows.py:42); a paused workflow records its next level and resume_workflow re-enters the loop there (executor.py:367). Executions are queryable via GET /api/workflows/executions.
Schedules
A Schedule (dtos.py:364) binds a registered workflow to one of four trigger types (TriggerType, dtos.py:58):
- cron — cron expression evaluated with
croniterwhen installed, else a simple*/Nminute fallback (scheduler.py:289). - interval — fires when
interval_secondselapsed sincelast_run(scheduler.py:282). - webhook —
handle_webhook(path, payload); optional HMAC-SHA256 signature check whenwebhook_secretis set, unauthenticated otherwise by design (scheduler.py:381). - event —
handle_event(event_type, payload)fans out to all listeners (scheduler.py:437).
WorkflowSchedulerEngine.start() (scheduler.py:190) loads persisted schedules and ticks every second (asyncio.sleep(1)), firing due schedules as strongly-referenced background tasks; stop() awaits in-flight tasks. Each schedule is persisted as <DATA_DIR>/schedules/<id>.json for crash recovery, with the id validated against ^[A-Za-z0-9_-]+$ to block path traversal (scheduler.py:40). The facade WorkflowScheduler (system.py:49) exposes schedule(), add_interval_schedule(), add_webhook_schedule(), add_event_schedule(); the factory wires it with agent framework, tool system, and memory manager and starts the loop as a background task (factory.py:252).
SkillDAG sidecar
The SkillDAG integration talks to an external skill-graph server (default http://localhost:8000, run separately; its code is not in this repo). Maintenance mode since 2026-08-15 (MCP-Arc.md §10): A/B evals showed no pass-rate delta and model-visible skills now ship in-process (use_skill tool + catalog section), so the sidecar is kept fail-open but no longer invested in. Everything is dormant unless SKILLDAG_ENABLED=true (skilldag/config.py:48; other knobs: SKILLDAG_URL, SKILLDAG_READ_GRAPH, SKILLDAG_WRITE_GRAPH, SKILLDAG_TIMEOUT, SKILLDAG_ENRICH_ENABLED, SKILLDAG_MINE_ENABLED).
Client
SkillDagClient (skilldag/client.py:40) is stdlib-urllib only and never raises: every call returns a {ok, status, data, error} envelope, and a disabled config short-circuits before any network I/O (client.py:68). Three endpoints are used: GET /api/v1/health, GET /api/v1/graphs/{g}/search?q=&top_k=&depth=, and POST /api/v1/graphs/{g}/mine-skills. Mining gets a 60s timeout because it invokes an LLM server-side; search/health use SKILLDAG_TIMEOUT (5s) / 2s (client.py:34).
Read path: context-block injection
enrich_with_skilldag(query) (integration.py:59) searches the read graph and renders a [SkillDAG context] block listing matching skills (skill_id: description), Related skills via typed edges: neighbors, and Avoid combining: conflicts, capped at 200 chars per snippet and 4 000 chars total. It returns "" on any failure, so callers append unconditionally. Call sites: direct chat (routes_query.py:654, joined with the memory block into the system prompt at routes_query.py:687) and agent runs, once per run inside _think (agents/base.py:786).
Write path: skill mining from finished exchanges
After a completed exchange, schedule_mining(prompt, response) (integration.py:151) fires mine_skills_after_query off the request path — asyncio.to_thread when a loop runs, else a daemon thread — which POSTs the pair to /mine-skills on the write graph. The server extracts reusable skills and returns {new_skills, existing_skills, skipped, total_extracted} (client.py:152). Call sites: end of the direct stream (routes_query.py:818) and the agent stream (routes_query.py:1140), both using the original unenriched prompt. Gated by SKILLDAG_MINE_ENABLED; failures are logged at debug and swallowed.
Propose-then-commit graph
Graph structure changes on the sidecar are two-phase (verified in the sidecar repo at F:/Kucatoo/Sites/Kucatoo-Code/SkillDAG/dev/skilldag_server/, outside this repo): propose_edge / propose_remove_edge / propose_retype_edge are dry-run previews that validate acyclicity and contradictions and return {would, related} without mutating (core/graph.py:823); edit_edge is the only write path, re-validating, appending to history, and saving (core/graph.py:888). These are exposed as REST POST /graphs/{id}/edges/propose|edit and as MCP tools whose descriptions mandate propose-before-commit. The Kucatoo-Code client does not call these — it only searches and mines; edge curation is an operator/MCP concern on the server.
Scope notes
- The word "episodic" does not appear in the code; the closest constructs are
MemoryAwareAgentstoring each task+result episode and the=== Relevant memory (from previous runs) ===block. docs/Current-State-App.mdclaimsWFStepType.SKILLraisesNotImplementedError; the current code implements it via the SkillLibrary (step_handlers.py:334) and raisesRuntimeErroronly when no skill library is wired.