18 KiB
adr_number, title, status_history, tier, authors, superseded_by, related_adrs, acceptance
| adr_number | title | status_history | tier | authors | superseded_by | related_adrs | acceptance | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 37 | Tool Reachability and Access Projection |
|
3 |
|
null |
|
|
Context
ADR-011 defines how tools declare resource slots and how the BindingResolutionService resolves slots to concrete resources. The specification (lines 20113–20321) details the three binding modes (contextual, static, parameter) and shows resource discovery queries like "What tools can modify this resource?" However, the current model only answers direct binding questions — whether a tool's slot type matches a specific resource's type. It does not formalize:
- Transitive reachability: A tool bound to a
git-checkoutcan transitively reach every file within that checkout, but this reach is implicit in the tool's implementation, not explicit in the DAG. - Inverse reachability: Given a specific file deep in the DAG, what tools can reach it? This requires walking up the containment hierarchy, not just checking direct type matches.
- Cross-equivalence reachability: A file accessible through both a git checkout and a Docker container mount might be reachable through tools bound to either ancestor chain.
- Access projection: When a tool is bound to an ancestor resource and needs to access a descendant, how is the descendant identified within the ancestor's access space? (e.g., relative path for filesystem, document URI for LSP, SQL query for database).
- Read/write routing: When a virtual resource has multiple physical manifestations, which tool should the system prefer for reads (semantic richness) versus writes (sandbox tracking)?
Decision Drivers
- Current binding resolution only answers direct type-match questions; a tool bound to a
git-checkoutimplicitly reaches all contained files but this reach is not formalized in the DAG - Inverse reachability ("what tools can reach this specific file?") requires walking up the containment hierarchy, not just checking direct type matches
- A virtual resource with multiple physical manifestations (git checkout, container mount, LSP document) may be reachable through tools bound to any ancestor chain
- Access projection must translate between namespaces (filesystem paths, LSP document URIs, container-internal paths) when a tool accesses a descendant through an ancestor binding
- Read and write operations benefit from different physical paths — semantic reads through LSP, sandbox-tracked writes through git — requiring explicit routing by richness and sandbox tracking capability
Decision
CleverAgents formalizes tool reachability as a transitive property through the resource DAG's contains edges, adds an access projection method to the ResourceHandler protocol, and defines a read/write routing algorithm that selects optimal tools and access paths based on virtual resource equivalence classes.
Design
Tool Reachability
Forward Reachability
Definition: The forward reachability set of a tool T bound to resource R is:
forward_reach(T, R) = {R} ∪ {d : d is a descendant of R via 'contains' edges}
Every resource in the forward reachability set can be accessed by tool T through resource R, subject to:
- The tool's access mode (read_only, read_write, write_only) constraining what operations are allowed.
- The descendant resource's effective writability (see ADR-036: Access Control Propagation).
- The access projection being defined for the (R.type, d.type) pair.
Example: A write_file tool bound to git-checkout at /repo has forward reachability over:
git-checkout "/repo"
└── fs-directory "/repo" (worktree root)
├── fs-directory "/repo/src"
│ ├── fs-file "/repo/src/main.py" ← reachable
│ └── fs-file "/repo/src/utils.py" ← reachable
└── fs-file "/repo/README.md" ← reachable
Inverse Reachability
Definition: The inverse reachability set of a resource r is the set of all tools that can transitively reach r:
inverse_reach(r) = {(T, R, path) : T is a tool with resource slot bound to R,
and r ∈ forward_reach(T, R),
and path = containment_path(R, r)}
Each entry in the inverse reachability set is a triple of (tool, binding resource, containment path from binding resource to target).
Algorithm:
- Walk up
containsedges fromrto collect all ancestors:ancestors(r) = {r, parent(r), parent(parent(r)), ...}. - For each ancestor
a, find all tools with resource slots whose type is compatible witha.resource_type. - Filter by access mode compatibility (e.g., if querying for write-reachability, only include tools with
read_writeorwrite_onlyaccess). - Return the set of (tool, ancestor, path_from_ancestor_to_r) triples.
Complexity: O(depth × |tools|) where depth is the DAG depth from r to root. In practice, depth is 3–6 and tool count is bounded, making this efficient.
Cross-Equivalence Reachability
When a resource r has a virtual parent (equivalence link), the system can also find tools that reach equivalent physical manifestations of the same virtual resource:
cross_equiv_reach(r) = inverse_reach(r)
∪ {(T, R, path) : for each sibling s of r under the same virtual parent,
(T, R, path) ∈ inverse_reach(s)}
This answers: "What tools can reach the same logical resource through any physical path?"
Example: File src/main.py exists as:
fs-fileundergit-checkout(reachable bywrite_file,edit_file)lsp-documentunderlsp-workspace(reachable by hypotheticallsp_hover,lsp_goto_deftools)fs-fileundercontainer-mount(reachable bydocker_exectools)
Cross-equivalence reachability for any of these physical manifestations returns all tools from all three paths.
Access Projection
When a tool is bound to resource R and needs to access a descendant resource d, the access projection computes how d is identified within R's access space.
The project_access Handler Method
Every resource type handler implements:
class ResourceHandler(Protocol):
# ... existing methods ...
def project_access(
self,
binding_resource: Resource,
target_resource: Resource,
containment_path: list[Resource],
sandbox: Sandbox | None,
) -> AccessProjection | None:
"""Compute how to reach target_resource from binding_resource.
Args:
binding_resource: The resource that the tool is bound to.
target_resource: The descendant resource to be accessed.
containment_path: The path of resources from binding_resource
to target_resource through 'contains' edges.
sandbox: The active sandbox, if any. Used for path rewriting.
Returns:
An AccessProjection if the target is reachable through this
handler, or None if the handler cannot project access to
the target type.
"""
...
AccessProjection
@dataclass(frozen=True)
class AccessProjection:
"""How to reach a target resource from a bound resource."""
# The access path in the binding resource's namespace
access_path: str
# The protocol/mechanism for access
protocol: str
# Whether this projection crosses a sandbox boundary
# (i.e., the access path goes through a different sandbox
# domain than the binding resource's domain)
crosses_sandbox: bool
# The sandbox boundary resource_id that the target
# resides in (may differ from binding resource's boundary)
target_sandbox_boundary_id: str | None
# Access capabilities through this projection
readable: bool
writable: bool
# Richness score for read routing (higher = more information)
# Filesystem: 1, LSP: 10, semantic index: 5, etc.
read_richness: int
Built-in Projection Examples
GitCheckoutHandler → fs-file:
def project_access(self, binding, target, path, sandbox):
# Compute relative path from checkout root to target file
rel_path = os.path.relpath(target.location, binding.location)
sandbox_path = sandbox.get_path() if sandbox else binding.location
return AccessProjection(
access_path=os.path.join(sandbox_path, rel_path),
protocol="filesystem",
crosses_sandbox=False, # same sandbox domain
target_sandbox_boundary_id=binding.resource_id,
readable=True,
writable=True,
read_richness=1, # raw file content only
)
LSPWorkspaceHandler → lsp-document:
def project_access(self, binding, target, path, sandbox):
return AccessProjection(
access_path=target.properties["document_uri"],
protocol="lsp-textdocument",
crosses_sandbox=True, # LSP reads from its own buffer
target_sandbox_boundary_id=None, # no sandbox for LSP
readable=True,
writable=True, # via workspace/applyEdit
read_richness=10, # semantic info: types, refs, diagnostics
)
ContainerInstanceHandler → fs-file (via container-mount):
def project_access(self, binding, target, path, sandbox):
mount = find_mount_in_path(path) # find container-mount in containment path
container_path = remap_to_container_path(target.location, mount)
return AccessProjection(
access_path=container_path,
protocol="container-exec",
crosses_sandbox=False, # within container's sandbox domain
target_sandbox_boundary_id=binding.resource_id,
readable=True,
writable=not mount.properties.get("read_only", False),
read_richness=1, # same as filesystem
)
Read/Write Routing
When the system needs to access a virtual resource (or any resource with multiple physical manifestations), it must choose which physical path and tool to use.
Canonical Write Target
Each virtual resource designates one physical manifestation as the canonical write target — the manifestation through which writes should be routed to ensure sandbox and checkpoint tracking.
Selection rules (in priority order):
- If exactly one physical manifestation is sandboxable, it is the canonical write target.
- If multiple are sandboxable, prefer the one with the strongest sandbox strategy:
git_worktree>snapshot>copy_on_write>transaction_rollback>none. - If still ambiguous, prefer the manifestation linked to the plan's primary project.
- The user can override canonical write target via resource configuration.
The canonical write target is stored on the virtual resource as metadata: canonical_write_resource_id.
Read Source Ranking
When routing a read, physical manifestations are ranked by read richness (the read_richness score from AccessProjection):
| Source | read_richness | Provides |
|---|---|---|
lsp-document |
10 | Type info, symbol table, diagnostics, go-to-def, references, completions |
| Semantic index (future) | 5 | Pre-computed symbol index, dependency graph |
fs-file via git-checkout |
1 | Raw file content, git history |
fs-file via container-mount |
1 | Raw file content |
git-tree-entry |
1 | File content at specific commit |
The system selects the highest-richness source that is:
- Available (the source resource exists and its handler is accessible).
- Current (not stale — coherence checks pass, or staleness is acceptable for the query).
- Compatible with the query type (some queries require semantic info, others just need raw content).
Routing Algorithm
route_access(virtual_resource, access_mode, query_type):
manifestations = physical_children(virtual_resource)
if access_mode == WRITE:
target = canonical_write_target(virtual_resource)
tools = inverse_reach(target) filtered by writes=True
return best_tool(tools), project_access(tool.binding, target)
if access_mode == READ:
ranked = sort(manifestations, key=lambda m: best_projection(m).read_richness, reverse=True)
for m in ranked:
if is_available(m) and is_current_enough(m, query_type):
tools = inverse_reach(m) filtered by read_only compatible
if tools:
return best_tool(tools), project_access(tool.binding, m)
# Fall back to any available manifestation
return fallback_read(manifestations)
Integration with Existing Binding Resolution
The access projection and routing system extends, not replaces, the existing BindingResolutionService. The flow becomes:
- Binding resolution (existing): Tool slots are resolved to concrete resources via contextual/static/parameter binding.
- Forward reachability (new): Once bound, the system computes the tool's transitive reachability set through the DAG.
- Access projection (new): When the tool accesses a specific descendant, the handler computes the access projection.
- Read/write routing (new): When the planner selects which tool to use for a specific virtual resource, the routing algorithm selects the optimal physical path.
Steps 1 is performed at tool activation time. Steps 2–4 are performed at execution time.
Constraints
- Access projection must be O(1) per handler invocation (no recursive DAG traversal within the projection itself — the containment path is pre-computed and passed in).
- Read/write routing must not introduce latency-sensitive decisions into the execution hot path. Routing can be pre-computed during the Strategize phase for known resource access patterns.
- Cross-equivalence reachability may return large result sets for virtual resources with many physical manifestations. Results should be lazily computed and filtered early.
- The
read_richnessscore is a heuristic. Tool authors can override it for custom resource types.
Consequences
Positive
- The system can answer "what tools can reach this file?" without the caller knowing the file's position in the DAG hierarchy.
- Read/write routing through different physical paths enables using the best tool for each access pattern (semantic reads through LSP, sandbox-tracked writes through git).
- Access projection formalizes the implicit knowledge that tools currently carry about how to reach descendant resources.
Negative
- The
project_accessmethod adds a new responsibility to every resource handler. Existing handlers must be updated. - Routing introduces decision-making that could produce suboptimal choices if
read_richnessscores are miscalibrated. - Cross-equivalence reachability creates coupling between otherwise independent physical resource hierarchies.
Risks
- The routing algorithm depends on LSP servers being available and current. If the LSP server is down, the system falls back to raw filesystem reads, but the fallback must be seamless.
- Access projection assumes that containment paths are stable during execution. If the DAG is modified during execution (e.g., auto-discovery adds new resources), projections may become stale.
Alternatives Considered
Flat tool-resource matching (status quo) — Tools are matched to resources only by direct type compatibility. Simple but cannot support transitive reachability or semantic read routing.
Resource URL schemes for access projection — Instead of handler-based projection, use URL schemes (e.g., file://, lsp://, docker://) to encode access paths. This is more declarative but less flexible — URL schemes cannot encode sandbox-specific path rewriting or cross-boundary awareness.
Compliance
- Forward reachability tests: Verify that a tool bound to a
git-checkoutcorrectly reaches all descendantfs-fileresources. - Inverse reachability tests: Verify that querying inverse reach for a deeply nested file returns tools bound to all ancestors.
- Cross-equivalence tests: Verify that querying reachability for one physical manifestation returns tools from equivalent manifestations.
- Access projection tests: Each built-in handler's
project_accessmethod is tested for correct path computation, sandbox path rewriting, and protocol assignment. - Routing tests: Read routing prefers LSP sources over filesystem sources. Write routing uses the canonical write target.