Repository navigation
Design: gather inside aft_search — the plugin sends the reasoning trace, a decision model walks the call graph from the hits #890
Description
1. Summary
The maintainer suggested this change: aft_search receives the reasoning trace that caused the agent to call it. The tool then extracts the call graph near the results and walks outward. At each step, a decision model selects the nodes to keep and decides if the walk continues. The comparison was to "sidekick": it works, but it is not sufficient.
This issue is a design for review before a draft PR. Please correct the shape early. A prototype starts in parallel, on the parts that do not depend on the open questions in section 8.
2. Relation to #192 and #152
#192 and PR #152 closed because a deterministic packer on the tool surface moves pack-composition judgment onto the calling model, and each new tool costs surface. This design keeps the judgment off the calling model: a decision model inside AFT selects what to keep. It also adds no tool and no agent argument; the trace travels next to the arguments, not in them.
3. Sidekick
Sidekick is not in this repository. AFT mentions it only as a helper route class: a sidekick bind does not start a semantic index (semantic_admission.rs:20). The #192 closing note describes a judged reader in the delegation model that selects what goes into a context pack; that may be the same component. Magic Context had a sidekick agent behind /ctx-aug for prompt augmentation and removed it in v0.42.0. Question 1 in section 8 asks which one is meant and where it is not sufficient.
4. Facts that the design uses
| Fact | Evidence |
|---|---|
OpenCode 1 gives a tool sessionID and messageID. |
@opencode-ai/plugin ToolContext (dist/tool.d.ts:2-4) |
| The plugin holds an SDK client and already reads session messages. | shared/last-assistant-model.ts:123-156 |
| On OpenCode 1, the reasoning and text parts of the current assistant message are in storage when the tool runs. | Live probe, 2026-10-25: a running bash call's message had its reasoning part (213 characters) stored 9 ms before the tool part. |
OpenCode 2 gives plugins generate.text({prompt, model?}) and session.message.get({sessionID, messageID}). |
@opencode/client 2.0.11 effect/api/api.d.ts:1550-1712; plugin context members in test/matrix/delta-audit-ga-2.0.11.md:33 |
Pi gives a tool sessionManager (read-only: getLeafEntry, getBranch), model and modelRegistry. |
pi-coding-agent extensions/types.d.ts:208-232 |
| OpenAI models frequently store empty reasoning text. | 3 days of data on one machine: gpt-6-luna reasoning parts average 28 characters; Claude, MiniMax, GLM and DeepSeek average 800-3,700. |
AFT has no chat-completion client. It has a reranker with off, onnx, remote and synapse backends. |
config.rs:620-660, commands/semantic_search/rerank/ |
| Extra request fields already travel next to the agent arguments. | edit_slot_survives, preview: bridge.ts:1084-1105, tool_call.rs:40-119 |
The call-graph store reports Ready, Building, Off, Unavailable, Suspended or Error, and does not block. |
context.rs:4865-4887; callers.rs:84-105 |
aft_search maps to semantic_search. Expansion fits after the ranked response and before the handler attaches metadata. |
subc_translate.rs:2460-2536; commands/semantic_search/mod.rs:1034-1046 |
5. Design
5.1 Trace input
The plugin collects the trace. The agent does not write it. The plugin reads
no message parts unless search.gather.enabled is true for that session.
While gather is off, the plugin collects no trace.
- OpenCode 1: read the current message with
messageID. Use thereasoning
andtextparts that come before this tool call. - OpenCode 2: the same parts through
session.message.get. The prototype must
confirm that the tool context hasmessageID. - Pi and OMP: the same parts from the leaf assistant entry. The prototype must
confirm that Pi stores the assistant message before it runs the tools. - Add the newest user message text in all hosts.
- Cap the trace at 6,000 characters. Keep the newest text.
- Send the trace as an out-of-band field
search_contexton the tool-call
request, the same way the plugin sendsedit_slot_survives. The tool schema
does not change. - If no trace is available, use the query only. The search does not fail.
- AFT does not log trace text. Logs show the length only.
5.2 Seeds and graph
- Run the current ranked search. Its results do not change.
- Take the top 5 hits that are code. Resolve each hit to the symbol that
contains it (resolve_containing_symbolfrom PR gather: deterministic context-pack tool (aft_gather) #152). - Get a pinned read snapshot of the call-graph store. Read the callers and
callees of each seed: one hop per round. - If the store is not
Ready, do not walk. Return the normal results with
one line that gives the store state.
5.3 Walk and decision model
Each round sends one request to the decision model with the trace, the query,
and the frontier. Each frontier node has an id, the name, the kind,
path:line, the signature, up to 3 doc lines, and its edge (for example
"called by seed parse_config"). The model returns JSON:
{"keep": [ids], "expand": [ids], "stop": true|false}.
Limits (defaults; configurable in user config):
| Limit | Default |
|---|---|
| Rounds | 3 |
| Nodes kept | 12 |
| Frontier nodes per round | 40 |
| Deadline for one decision | 3 s |
| Deadline for the full walk | 8 s |
| Output added to the result | 12 KB, inside the current 50 KB cap |
If the model times out, fails, or returns JSON that is not valid, that round
uses the deterministic order from PR #152: seed order, then hop
distance, then reranker score when the user configures search.rerank. The
result tells the agent that the round used the fallback. A walk failure does
not make the search fail.
5.4 Decision-model channel (open question)
| Option | Works on | Credentials | Cost |
|---|---|---|---|
| A. AFT calls an OpenAI-compatible chat endpoint. | All hosts, standalone and daemon | User config only (endpoint, model, api_key_env), as search.rerank.remote |
New HTTP client in the engine; the trace goes to that endpoint |
| B. The host runs the model. | OpenCode 2 (generate.text), Pi (model registry). Not OpenCode 1. |
The host's providers; no new keys | The plugin runs the walk loop and the engine runs graph steps; one more request per round |
| C. A daemon LLM-runner module runs it. | Daemon mode only | The daemon's credential custody | Depends on that module |
Recommendation: a DecisionModel trait in the engine with backend A first,
because it works on every host and in the two modes. Add B when AFT stops
support for OpenCode 1. The maintainer selects the option.
5.5 Privacy and credentials
- Egress. Backend A sends the trace (reasoning, visible text and the newest
user message) and symbol bodies to an endpoint that the engine calls
directly. That request does not go through host hooks, so host redaction
(for example a secret-guard plugin on OpenCode 2) does not see it. Backend B
uses the host's providers, so host masking stays in the path. The issue
gives three choices for A: the plugin sends a trace that host redaction has
already processed; the engine applies a redaction hook before each request;
or the documentation states the risk and recommends a local endpoint. - Credentials in daemon mode.
api_key_envreads the environment of the AFT
process. In daemon mode, that environment comes from the module
configuration of the daemon. The daemon-mode choice is a custody grant for
the module (for example a vault grant), the same path that
search.rerank.remotewould need. - AFT does not log trace text or decision-model requests. Logs show lengths,
counts and timings only.
5.6 Caching
- Neighbor lists: keyed by the call-graph generation.
- Decisions: keyed by a hash of the trace, the query and the frontier ids,
for 10 minutes for each session. The same search again does not call the
model again.
5.7 Output
The ranked results come first and do not change. Then one section:
Related code (gathered for this search, 2 rounds, 7 of 31 nodes kept):
crates/aft/src/config.rs:612 RerankConfig: struct · called by seed resolve_rerank
<symbol body, cut to budget>
...
gather: model=<name> rounds=2 considered=31 kept=7 fallback=none 2.4s
5.8 Configuration
"search": {
"gather": {
"enabled": false,
"decision": { "backend": "endpoint", "endpoint": "...", "model": "...", "api_key_env": "..." },
"max_rounds": 3, "max_nodes": 12, "deadline_ms": 8000
}
}- Off by default.
- The decision backend, endpoint and key are user-tier only. A project can turn
gather off but cannot turn it on or change the endpoint. - Gather runs only for natural-language queries. A
pattern-only search does
not walk.
6. Reuse from PR #152
| Part | Use |
|---|---|
resolve_containing_symbol |
Port to current main. |
collect_callgraph_neighbors, collect_callees_for_seed |
Rewrite on the pinned store snapshot (callers_result, call-tree queries). |
build_pack ordering and provenance |
Use as the deterministic fallback order. |
| Budget-aware rendering | Use render_symbol_within_budget (symbol_render.rs:209). |
Mode handling (question/symbol) |
Do not port. aft_search already has the query. |
7. Test plan
- Unit: trace capping, JSON parsing of decisions, each fallback path, limits.
- Integration: a fixture repository with a known call chain and a fake
decision model. The test checks that the walk keeps the chain and stops. - A test that the store states other than
Readyreturn the plain search. - Mutation: remove the fallback and the deadline, and the tests fail.
- Host tests: each plugin sends
search_contextand does not put it in the
agent arguments.
8. Questions for the maintainer
- Which sidekick, and where is it not sufficient?
- Decision-model channel: A, B or C (5.4)? For A: which redaction choice
(5.5)? - Default: off, or on when the user configures a decision backend?
- Gather on each natural-language search, or only when a trace is available?
Activity