Stop Letting Coding Agents Guess Call Chains: Tracing Blast Radius and Temporal Memory in FalkorDB
When you change a helper function in a backend service, ask an LLM coding assistant, it will likely do one of two things: retrieve the function name from a vector database, or extract a few top-k chunks from a vector dat
When you change a helper function in a backend service, ask an LLM coding assistant, it will likely do one of two things: retrieve the function name from a vector database, or extract a few top-k chunks from a vector database and guess.
This is okay if the function is called directly from the test. It fails when your call chain is two or three hops long, or when an agent fails to find a solution to a problem today, saves the fact that it failed and moves on to tomorrow, when a commit alters the logic in the same module.
I created GraphTriage (github.com/Nimit15/GraphTriage): an Incident resolution and session memory system supported by FalkorDB Cloud for WeMakeDevs Γ FalkorDB Graph Hacks 2026 (Track 1: Agents That Act on Connected Data, Track 2: Agent Memory and Coordination).
GraphTriage is not a visualization supplement to the graph database, but rather it poses four specific questions using FalkorDB before an agent acts:
- Does the function or the test you are asking for exist in the graph? (If not, say NO right away rather than guess!)
- What is the exact call chain between the failed test and the broken function?
- What downstream functions and tests are contained within a blast radius of a change?
- Have we seen this fault signature beforeβand is that prior finding still
ACTIVE, or has a newer commit marked itSUPERSEDED?
1. The Graph Schema mapping in FalkorDB
FalkorDB contains both repository (Track 1) and three-tier temporal memory (Track 2) data. Each workspace is separated at the database level using db.select_graph(f"graphtriage_ws_{workspace_id}"), to prevent memory leakage across tenants.
graph LR
M[":Module"] <-- DEFINED_IN --- F1[":Function (Caller)"]
F1 -- CALLS --> F2[":Function (Callee)"]
T[":Test"] -- COVERS --> F1
I[":EpisodicIncident"] -- IMPLICATES --> F2
SF_NEW[":SemanticFact (ACTIVE)] -- APPLIES_TO --> F2
SF_OLD[":SemanticFact (SUPERSEDED)]]
PB[":ProceduralPlaybook"] -- REMEDIATES --> SF_NEW
Nodes and Relationships
Code Structure Nodes:
(
:Module) β pulled the source files from the repository.(
:Function) β functions with module paths and line metadata, connected by [:DEFINED_IN] and directed [:CALLS] edges.(
:Test) β test cases exercising the functions they cover via [:COVERS] edges.
Three-Tier Memory Nodes:
(
:EpisodicIncident) β raw incident data (stack trace, failing test name, session id, timestamp).(
:SemanticFact) β verified root-cause statements with temporal lifecycle properties (valid_from,valid_to,status: βACTIVEβ | βSUPERSEDEDβ)(
:ProceduralPlaybook) - reusable remediation steps attached to recurrent fault signatures (e.g.KeyError: 'income'), keeping track ofusage_countover sessions.
Figure 1. GraphTriage's local FalkorDB graph showing the code entities and relationships used for incident analysis.
2. Track 1: Explaining How the Graph Carries Out the Work (A Cypher That Contains No Text Written by a Large Language Model)
A design rule that I adhered to right from the beginning was that the agent should never generate raw Cypher queries at runtime. Allowing an LLM to produce ad-hoc Cypher queries would lead to syntax errors and the invention of hallucinated edge labels. In order to avoid this, the agent interacts with FalkorDB only via the parameterized methods in src/core/incident_resolver.py and the fixed tools that are available through the official Model Context Protocol (MCP) SDK (src/mcp/mcp_server.py):
trace_blast_radiusfind_failure_pathrun_targeted_tests
Step 1: Refusing to Speculate on Unknown Entities
Should you inquire of a typical RAG agent regarding a function that has been renamed or deleted, it will readily retrieve chunks of code that look similar and then fabricate an impact analysis.
In the function IncidentResolver._validate_entity_existence(), each analysis begins with a parameterized check for existence in FalkorDB:
def _validate_entity_existence(self, entity_name: str) -> bool:
query = """
MATCH (n)
WHERE (n:Function OR n:Test OR n:Module)
AND n.name = $entity_name
RETURN count(n) AS count
"""
result = self.graph.query(query, {"entity_name": entity_name})
return result.result_set[0][0] > 0 if result.result_set else False
If FalkorDB returns 0, analyze_impact() halts immediately and returns status: "UNVERIFIED_ENTITY" with hops: 0 and an explicit refusal reason:
{
"status": "UNVERIFIED_ENTITY",
"hops": 0,
"function_name": "non_existent_helper",
"reason": "Function 'non_existent_helper' not found in graph; refusing to speculate without graph evidence",
"affected_functions": [],
"impact_path": []
}
Every decision the agent makes is traceable back to graph state: if the node isn't in FalkorDB, the agent refuses to guess.
Step 2: Tracing Real Bugs in sandbox_service
To test GraphTriage end-to-end without mocks, sandbox_service/app.py models a multi-function service (7 functions and 12 tests) containing three intentional regressions:
-
process_user_data: raisesKeyError: 'income'when optional dictionary keys are missing. 2.calculate_growth_rate: uses bitwise XOR (^) instead of exponentiation (**). 3.calculate_compound_interest: drops the exponent term in the compound formula.
When test_incident_process_user_data_missing_income fails, GraphTriage queries the call chain ([:COVERS]$\rightarrow$ [:CALLS]) in FalkorDB to trace the path from the failing test to the underlying function, then computes the blast radius (calculate_blast_radius) using PageRank (CALL algo.pageRank('Function', 'CALLS') with a < 2 node guard that falls back cleanly to direct relationship degree scoring).
Instead of re-running all 12 tests in the sandbox on every edit, the agent queries FalkorDB for only the :Test nodes connected to the affected subgraph and executes pytest on that targeted subset.
3. Track 2: Multi-Agent Handoffs & Replacing Old Information
When we store debugging history in a vector index that only adds new entries, old conclusions can stick around forever without anyone noticing.
In the files src/core/multi_agent_pipeline.py and src/core/temporal_memory.py, the GraphTriage system manages three specific agents. These agents pass information between each other using a structured process called AgentHandoff, with FalkorDB storing the data:
-
The
TriageAgentlooks at the stack trace, pulls out a standard signature for the error (likeKeyError: 'income'), records it as an EpisodicIncident, and then checks FalkorDB to see if there are any active SemanticFacts. 2. TheBlastRadiusAgenttakes the information from the TriageAgent, uses FalkorDB to find out how the failure spread and which functions were affected, and adds that graph evidence. 3. The MemoryCuratorAgent saves confirmed findings about the root cause as SemanticFact nodes and either finds or creates a ProceduralPlaybook.
Marking Old Facts as Replaced with [:SUPERSEDED]
Imagine a new code change affects how process_user_data handles default values. Instead of deleting the old SemanticFact (which would lose the debugging history) or just adding a new, conflicting fact, we use a function called supersede_fact(). This function sets an expiration date for the old fact, marks it as SUPERSEDED, and connects the new fact to the old one:
# Initial architectural finding saved in Session 1
memory.create_semantic_fact({
"statement": "process_user_data should use user.get('income', 0)",
"valid_from": "2026-10-17T10:00:00Z",
"status": "ACTIVE"
})
# Superseded after schema update in Session 2
memory.supersede_fact(old_fact_id, {
"statement": "process_user_data validates schema via default dict mapping",
"valid_from": "2026-10-18T14:00:00Z",
"status": "ACTIVE"
})
In Cypher, closing the old fact while preserving the lineage looks like this:
MATCH (old:SemanticFact {fact_id: $old_fact_id})
SET old.valid_to = $timestamp,
old.status = 'SUPERSEDED'
CREATE (new:SemanticFact {
fact_id: $new_fact_id,
statement: $statement,
valid_from: $timestamp,
valid_to: null,
status: 'ACTIVE'
})-[:SUPERSEDED]->(old)
When a future session queries memory, the fast retrieval path filters strictly on status = 'ACTIVE' AND valid_to IS NULL. The agent gets only current truth in its context window, while the [:SUPERSEDED]chain preserves the full audit trail.
Figure 2. GraphTriage's incident-resolver and temporal-memory tests running against a local FalkorDB instance (19 passed).
4. Engineering Lessons From Setting Up FalkorDB Cloud
When I built and tested this on Windows with a live FalkorDB Cloud, I learned some practical things:
1.
Centralizing Cloud Connection and Default Parameter Issues (src/core/db_connection.py):
At first, having default host="localhost" in our class constructors would quietly ignore the .env variables when setting up test data. We fixed this by putting all connection code into one place (db_connection.py). This setup loads .env variables and allows overriding them, splits the host and port, and has a fallback for SSL. Now, switching between our local Docker setup (localhost:6379) and FalkorDB Cloud is easy.
2.
MCP Server Startup and Module Name Conflicts:
We named our local folder src/mcp/, but this conflicted with the official mcp package we installed through pip. This happened when we set PYTHONPATH=/app/src in Docker. By changing to import from the project root (using PYTHONPATH=/app and running commands like python -m src.mcp.mcp_server) and giving InitializationOptions() to mcp_server.run(), we fixed the startup problems.
2.
Comparing Step-by-Step Path Finding vs. Wildcard Shortest Path:
In OpenCypher, how patterns with unbounded or undirected paths work can be different on various graph databases compared to Neo4j. We used specific directed relationships (like [:COVERS] and [:CALLS]) along with checking the call chain step-by-step and using a fallback based on how connected a node is. This made our path tracing completely predictable in all 8 integration tests. All tests passed, with one warning, and it took 10.49 seconds against the live FalkorDB Cloud.
5. Current Limitations & Next Steps
Language Parser Scope: The system currently takes in Python modules and follows
pytestconventions; the next logical step is to expand the AST loader to support TypeScript and Go using Tree-sitter.Containerized Test Runner: The current test execution runs in a subprocess with a time limit; putting test runs inside containers would make it more reliable for use in multi-user CI pipelines.
Repository & Disclosure
GitHub Repository: https://github.com/Nimit15/GraphTriage
Hackathon Tracks: Track 1 (Agents That Act on Connected Data) and Track 2 (Agent Memory and Coordination)
Development & AI Disclosure: This project was built for WeMakeDevs Γ FalkorDB Graph Hacks 2026. The graph schema design, FalkorDB Cloud connection layer, entity-existence refusal guard, call-chain and blast-radius logic, 3-stage agent handoff flow and temporal
SUPERSEDEDmemory lifecycle (~75% of the engineering work) were. Checked by me. I used Claude Code (~25%) as an IDE coding assistant, for FastAPI/MCP scaffolding, synthetic sandbox bugs and initial test fixtures.
Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.