diff --git a/CHANGELOG.md b/CHANGELOG.md index 76fc10110..5a49a08f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +- Add the bounded `compass.query.agent-view/1` projection for coding agents. + Typed CLI and MCP query text now lead with result state, answer, and caveats; + `--format agent-json` and MCP `agentView` expose the same deterministic + source-linked view while raw query JSON remains unchanged. Discovery text + keeps its v2 cursor ledger and adds only an answer-first fixed header. + ## 0.3.26 - 2026-09-15 - Make query failures and paths more trustworthy: exact-looking missing symbols diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 8e10b6a7f..3bb597dca 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -94,6 +94,22 @@ history profiles, and cache identities. ## Evolving contracts +### Agent Query View + +Compass adds the additive strict projection `compass.query.agent-view/1` for +typed CLI and MCP consumers. It is derived from, and digest-bound to, the raw +`compass.query/1` or `compass.query.discovery/1` response. The raw CLI `json` +shape, MCP `structuredContent.result`, graph schemas, and discovery +`compass.query.discovery-text-page/2` cursor meaning are unchanged. + +The typed commands accept `--format agent-json`; default text is an +answer-first presentation. MCP keeps `compass.mcp.tool-result/1` and adds the +optional `agentView` sibling plus a code-query `semanticResultDigest`. Existing +consumers may ignore the optional projection. Consumers that consume Agent +View must reject unknown major versions, enforce the documented bounds, and +distinguish `no_match`, `needs_resolution`, `no_path`, source truncation, and +projection truncation from a positive complete answer. + Immutable history now accepts up to 5 GiB of aggregate authoritative key and value bytes per realization, raised from 512 MiB. The history schema and canonical encoding are unchanged, as are the per-key, per-value, per-tree, diff --git a/advisor-plans/025-agent-readable-query-output.md b/advisor-plans/025-agent-readable-query-output.md new file mode 100644 index 000000000..13cf5457b --- /dev/null +++ b/advisor-plans/025-agent-readable-query-output.md @@ -0,0 +1,613 @@ +# Plan 025: Make query output answer-first for coding agents + +> **Executor instructions**: Follow this plan step by step. Run every +> verification command and confirm the expected result before moving to the +> next step. If anything in the "STOP conditions" section occurs, stop and +> report; do not improvise. When done, update this plan's row in +> `advisor-plans/README.md` unless a reviewer told you they maintain the index. +> +> **Normative design**: Read +> [`advisor-plans/designs/025-agent-readable-query-output-design.md`](designs/025-agent-readable-query-output-design.md) +> completely before editing. This plan restates the implementation-critical +> decisions, but the design owns the full schema and outcome rules. +> +> **Drift check (run first)**: +> +> ```bash +> git diff --stat b14f6907..HEAD -- \ +> crates/compass-model/src/query_contract.rs \ +> crates/compass-query/src/lib.rs \ +> crates/compass-query/src/code_query.rs \ +> crates/compass-query/src/discovery_text.rs \ +> crates/compass-output/src/lib.rs \ +> crates/compass-output/src/review.rs \ +> crates/compass-cli/src/code_query_commands.rs \ +> crates/compass-cli/src/lib.rs \ +> crates/compass-cli/src/help.rs \ +> crates/compass-mcp/src/lib.rs \ +> crates/compass-mcp/src/code_query.rs \ +> crates/compass-cli/assets/compass-skill \ +> crates/compass-cli/assets/compass-integrations \ +> docs COMPATIBILITY.md MIGRATION.md CHANGELOG.md +> ``` +> +> If an Agent View, answer-first query projection, changed MCP result envelope, +> or changed discovery pagination ledger has landed, reconcile it with the +> design and stop rather than creating a parallel contract. + +## Status + +- **Priority**: P1 — highest-leverage agent-facing output improvement +- **Effort**: L (six implementation phases) +- **Risk**: MED — public text and additive machine output change, raw query semantics stay fixed +- **Depends on**: none +- **Coordinates with**: Plan 018 MCP workflow prompts; if both are selected, land this first +- **Category**: direction / DX / output +- **Planned at**: commit `b14f6907`, 2026-09-16 + +## Why this matters + +Compass already retrieves source-grounded nodes, relationships, paths, and +diagnostics. An agent still pays a large interpretation cost: MCP text gives +only counts, raw JSON requires joining arrays by ID, and caveats that invalidate +an answer can arrive after graph details. A deterministic Agent Query View +turns the same evidence into an answer-first, bounded result without changing +ranking, resolution, provenance, or graph semantics. + +The intended outcome is not prettier prose. It is a typed result in which an +agent can determine, without guesswork, whether there is an exact answer, +whether evidence is inferred or ambiguous, whether execution was truncated, +what source-backed entities and relationships matter, and what exact bounded +action to take next. + +## Current state + +### Authoritative query contracts are flat evidence collections + +`crates/compass-model/src/query_contract.rs:358-376`: + +```rust +pub struct DiscoveryQueryResponse { + pub schema: String, + pub question: String, + // direction, scope, and traversal fields + pub seeds: Vec, + pub nodes: Vec, + pub edges: Vec, + pub diagnostics: Vec, + pub limits: DiscoveryLimits, + pub stats: DiscoveryStats, + pub omissions: DiscoveryOmissions, + pub truncated: bool, +} +``` + +`crates/compass-model/src/query_contract.rs:535-548`: + +```rust +pub struct CodeQueryResponse { + pub schema: String, + pub operation: CodeQueryOperation, + pub results: Vec, + pub nodes: Vec, + pub edges: Vec, + pub files: Vec, + pub paths: Vec, + pub diagnostics: Vec, + pub limits: CodeQueryLimits, + pub truncated: bool, +} +``` + +These remain the authoritative raw contracts. Do not add display-only fields +to either schema. + +### MCP text discards the useful evidence + +`crates/compass-mcp/src/lib.rs:1042-1072` and `1085-1120` render only counts: + +```rust +let text = format!( + "{:?}: {} nodes, {} edges, {} paths{}", + response.operation, + response.nodes.len(), + response.edges.len(), + response.paths.len(), + // ... +); +``` + +The raw evidence is available in `structuredContent`, but an agent or client +that prioritizes MCP text cannot identify the target, relationships, caveats, +or next action. + +### CLI rendering is duplicated above the output crate + +`crates/compass-cli/src/code_query_commands.rs:226-311` constructs typed-query +text locally. It joins node labels for paths, but it prints all nodes before +non-no-match diagnostics and has no versioned compact machine projection. + +### Discovery text puts mechanism before answer + +`crates/compass-query/src/discovery_text.rs:156-218` prints match signal, seed +terms, counts, direction, ambiguity count, coverage, truncation, traversal, +relationship contexts, and scope before the first entity. Diagnostics are +ordinary paginated entries at lines 551-568. The current cursor is +`compass.query.discovery-text-page/2` and binds to the stable entry ledger by +`section`, `item`, and `offset`. + +### Existing output precedent + +`crates/compass-output/src/review.rs` is the pattern to follow: + +- typed machine data retains full IDs; +- readable text shortens presentation-only identifiers; +- missing evidence and omissions are explicit; +- render size is bounded; +- text and JSON derive from the same verified domain result. + +`crates/compass-output/tests/pr_review.rs:100-133` verifies machine/human +projection parity without requiring human text to carry every full identifier. + +### Product constraints + +The implementation must retain these documented rules: + +- `docs/design/principles.md`: structure before similarity, evidence stays + attached, deterministic output, bounded work, and explicit machine contracts; +- `docs/implementation/extending-compass.md`: output transformations belong in + `compass-output`; direction, parallel edges, provenance, escaping, bounds, + and semantic-equivalence tests are mandatory; +- `AGENTS.md`: CLI/MCP stay thin; unknown majors fail explicitly; a limit is + not an empty result; ambiguous identities are never first-match selected. + +## Target contract + +Add strict schema `compass.query.agent-view/1` in `compass-output` with these +top-level fields: + +```text +schema, request, status, answer, +primaryResults, relationships, paths, +caveats, nextActions, omissions, identity, +sourceTruncated, projectionTruncated +``` + +Critical status fields remain separate: + +```text +resultState answered | candidates | needs_resolution | no_match | no_path +matchState exact | fuzzy | ambiguous | none | not_applicable | unknown +evidenceState exact | inferred | mixed | ambiguous | none +sourceExecution complete | partial +projection complete | partial +coverage incomplete | unknown +``` + +The fixed profile caps primary results at 12, relationships at 24, paths at 5, +caveats at 16, next actions at 5, serialized view size at 256 KiB, rendered +text at 64 KiB, and one scalar at 512 Unicode scalar values. Every omission is +counted; required status/identity/blocker records are never dropped. + +Raw `compass.query/1`, `compass.query.discovery/1`, and raw CLI JSON stay +unchanged. + +## Commands you will need + +Use one target directory for this exact worktree and no other checkout: + +| Purpose | Command | Expected on success | +| --- | --- | --- | +| Target preflight | `test -d /Volumes/Workspace && mkdir -p /Volumes/Workspace/crabbuild-target/compass-7af7-agent-view && test -w /Volumes/Workspace/crabbuild-target/compass-7af7-agent-view` | exit 0 | +| Query tests | `CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-7af7-agent-view cargo test -p compass-query --locked` | all pass | +| Output tests | `CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-7af7-agent-view cargo test -p compass-output --locked` | all pass | +| CLI contract | `CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-7af7-agent-view cargo test -p compass-cli --test code_query_cli --locked` | all pass | +| MCP contract | `CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-7af7-agent-view cargo test -p compass-mcp --test code_query_tools --locked` | all pass | +| Install assets | `CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-7af7-agent-view cargo test -p compass-cli --test install_cli --locked` | all pass | +| Focused Clippy | `CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-7af7-agent-view cargo clippy -p compass-query -p compass-output -p compass-cli -p compass-mcp --all-targets --all-features --locked -- -D warnings` | exit 0 | +| Query qualification | `CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-7af7-agent-view python3 scripts/qualify_query_relevance.py` | thresholds and backend parity pass | +| Product boundary | `sh scripts/check_product_boundary.sh` | exit 0 | +| Format | `cargo fmt --all -- --check` | exit 0 | +| Workspace baseline | `CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-7af7-agent-view cargo clippy --workspace --lib --bins --locked -- -D warnings && CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-7af7-agent-view cargo test --workspace --lib --bins --locked` | exit 0 | + +## Scope + +**In scope**: + +- `crates/compass-query/src/lib.rs` +- `crates/compass-query/src/discovery_text.rs` +- a focused query digest module if extracting it keeps ownership clear +- `crates/compass-query/tests/query_contract.rs` or a new focused digest test +- `crates/compass-output/src/agent_query.rs` (new) +- `crates/compass-output/src/lib.rs` +- `crates/compass-output/tests/agent_query.rs` (new) +- `crates/compass-cli/src/code_query_commands.rs` +- discovery formatting integration in `crates/compass-cli/src/lib.rs` +- `crates/compass-cli/src/help.rs` +- `crates/compass-cli/tests/code_query_cli.rs` +- `crates/compass-mcp/src/lib.rs` +- `crates/compass-mcp/src/code_query.rs` only if invocation context belongs there +- `crates/compass-mcp/tests/code_query_tools.rs` +- applicable assistant assets and install tests +- `docs/implementation/query-engine.md` +- `docs/reference/commands.md` +- `docs/reference/outputs.md` +- `docs/guides/integrating-compass.md` +- `docs/guides/exploring-a-codebase.md` +- `COMPATIBILITY.md`, `CHANGELOG.md`, and `MIGRATION.md` only if the final + compatibility decision requires user action +- `advisor-plans/README.md` status update + +**Out of scope**: + +- graph extraction, ranking, search, traversal, resolution, or graph schemas; +- CompassQL result rendering; +- PR, Agent Graph, legacy compatibility, or task-context tools; +- source excerpts in the compact view; +- a model/provider-generated answer; +- MCP workflow prompts from Plan 018; +- ranked execution flows from Plan 017; +- changing the default query limits; +- deleting or renaming existing tools; +- advancing discovery cursor version 2 while its entry ledger remains intact. + +## Git workflow + +- Suggested branch: `codex/agent-readable-query-output` +- Use focused conventional commits matching repository history, for example: + - `feat(output): add agent query view contract` + - `feat(cli): render answer-first query output` + - `feat(mcp): expose agent-readable query results` + - `docs(query): document the agent view contract` +- Do not push, open a PR, merge, or release unless explicitly instructed. + +## Phase 1: Add canonical source-result identity + +**Context**: Discovery already has `discovery_response_digest`; ordinary +`CodeQueryResponse` values do not have a public semantic digest. Agent View +must bind to its exact raw result without making the renderer own query +identity. + +### Changes + +1. Add `code_query_response_digest(&CodeQueryResponse)` in `compass-query`. +2. Clone the response, call `sort_stable`, serialize the full response, and + return lowercase SHA-256 without a prefix, matching discovery's helper. +3. Re-export it from `crates/compass-query/src/lib.rs`. +4. Add tests proving: + - stable-equivalent ordering yields one digest; + - a changed node, edge direction, path, diagnostic, truncation flag, or + limit changes the digest; + - repeated JSON/store results have the same digest. +5. Do not add the digest to `CodeQueryResponse`; it belongs in transport/view + envelopes. + +**Verify**: + +```bash +CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-7af7-agent-view \ + cargo test -p compass-query --locked code_query_response_digest +``` + +Expected: all digest tests pass and no query result fixture changes. + +## Phase 2: Implement the strict Agent Query View + +**Context**: Presentation-only derivation belongs in `compass-output`, which +already depends on `compass-model` and `compass-query`. Do not make +`compass-query` depend on `compass-output`. + +### Changes + +1. Add `crates/compass-output/src/agent_query.rs` and export its public types + and functions from `lib.rs`. +2. Implement the exact contract, enums, fixed limits, outcome precedence, + diagnostic-to-caveat mapping, stable sorting, omission accounting, source + result identity, and view digest from the design. +3. Accept an explicit invocation context containing: + - operation; + - ordered question/operands; + - graph identity; + - build generation identity; + - optional discovery continuation cursor and evidence-hidden flag. +4. Provide separate pure constructors for `CodeQueryResponse` and + `DiscoveryQueryResponse`. They may inspect only the request context and + response; they must not open a graph or re-run resolution. +5. Inline readable endpoint labels and path steps while retaining full IDs, + direction, weakest resolution, weakest confidence, and source sites. +6. Generate only the closed headline templates and bounded next actions in the + design. Use argv arrays and JSON objects, never a shell command string. +7. Implement `AgentQueryView::validate`, strict `from_json`, canonical JSON, + and `viewDigest` verification. +8. Follow `review.rs` for bounded output and compact/full identifier behavior. + +### Required unit/integration cases + +Create `crates/compass-output/tests/agent_query.rs` covering: + +- exact search; +- no exact match with fallback candidates; +- ambiguous candidates with no selected answer; +- exact zero callers; +- exact and mixed-evidence caller/callee relationships; +- impact paths traversed opposite published edge direction; +- exact node trail, no path, and direction mismatch; +- broad discovery; +- source truncation versus projection truncation; +- incomplete coverage and stale-source caveats; +- invalid basis/endpoint references; +- deterministic ordering and digest; +- control characters, bidirectional controls, and forged section/footer text; +- the mandatory prefix exceeding its byte limit as an explicit error. + +**Verify**: + +```bash +CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-7af7-agent-view \ + cargo test -p compass-output --test agent_query --locked +``` + +Expected: all Agent View projection, validation, escaping, and bound tests pass. + +## Phase 3: Add shared answer-first text rendering + +**Context**: CLI and MCP must not maintain separate summaries. The renderer +must derive solely from a validated `AgentQueryView`. + +### Changes + +1. Add `render_agent_query_text` and a smaller + `render_agent_query_header_lines` in `compass-output`. +2. Render sections in this order: + + ```text + RESULT + ANSWER + CAVEATS + PRIMARY RESULTS + PATHS + RELATIONSHIPS + NEXT ACTIONS + DETAILS + ``` + +3. Put blocker and warning caveats before primary results. Never paginate them + away. +4. Escape every untrusted scalar and enforce the 64 KiB text bound using the + design's whole-record removal order. +5. For natural discovery pagination, add a new + `render_discovery_text_page_with_prefix` (or equivalently named) function in + `compass-query` that accepts already escaped fixed prefix lines from the + caller, then uses the existing entry ledger and footer unchanged. Keep the + current `render_discovery_text_page` as a compatibility wrapper using its + existing prefix. +6. Do not reorder, insert, or remove discovery page entries. Preserve + `section`, `item`, `offset`, cursor validation, and + `compass.query.discovery-text-page/2`. + +**Verify**: + +```bash +CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-7af7-agent-view \ + cargo test -p compass-query --locked discovery_text +CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-7af7-agent-view \ + cargo test -p compass-output --test agent_query --locked text +``` + +Expected: old v2 cursors still address the same next entry; answer-first text +passes ordering, escaping, and size assertions. + +## Phase 4: Wire CLI text and Agent JSON without changing raw JSON + +**Context**: `code_query_commands.rs` currently renders text locally, while the +natural-discovery path in `lib.rs` owns cursor handling. Both adapters have +access to the query engine identities required by Agent View. + +### Changes + +1. Refactor typed command execution to retain a bounded invocation descriptor + and the engine's `graph_identity()` and `build_generation_identity()` beside + the raw response. +2. Replace the local `render_text` implementation with the shared output + projector/renderer. +3. Add `--format agent-json` to `ask`, `search`, `callers`, `callees`, `impact`, + `explore`, `node`, and natural discovery. +4. Keep `--format json` byte-semantically equivalent to the existing raw query + response. Do not wrap or add fields. +5. For natural discovery text: + - construct one Agent View from the full response; + - render its fixed answer/caveat header; + - pass that header to the prefix-aware discovery page renderer; + - retain the current entries, footer, next cursor, semantic result digest, + and evidence behavior. +6. Reject `agent-json` with `--cursor`, `--text-budget`, `--evidence`, or + `--result-envelope` using an actionable usage error. +7. Update help text and examples. + +### Required CLI tests + +Extend `crates/compass-cli/tests/code_query_cli.rs` to assert: + +- result state and answer precede nodes/edges; +- ambiguity/no-match caveats precede fallback candidates; +- exact zero-neighbor queries are `answered`, not `no_match`; +- `agent-json` parses as `compass.query.agent-view/1` and validates; +- raw JSON equals the pre-projection shape and contains no `agentView`; +- discovery page 1 and a carried v2 cursor return disjoint unchanged detail + entries with the same semantic result identity; +- control text cannot forge `RESULT`, `ANSWER`, `CAVEATS`, or `Pagination`; +- invalid format combinations fail on stderr with nonzero status. + +**Verify**: + +```bash +CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-7af7-agent-view \ + cargo test -p compass-cli --test code_query_cli --locked +``` + +Expected: all typed and natural query CLI tests pass; raw JSON assertions are +unchanged except for new tests. + +## Phase 5: Replace MCP count-only text and add the structured projection + +**Context**: MCP is the primary coding-agent interface. It must expose the same +view as CLI without weakening its raw audit result or transport bounds. + +### Changes + +1. In typed and discovery tool invocation, capture request operands plus the + query engine graph/build-generation identities. +2. Project one `AgentQueryView` and use `render_agent_query_text` for MCP + `content`. +3. Extend `transport_envelope_with_digest` through a typed helper that accepts + an optional Agent View and emits it as sibling `agentView`. +4. Preserve the existing `result`, `transportTruncation`, and discovery + `semanticResultDigest` meanings. Add the code-query source-result digest to + `semanticResultDigest` for typed code-query tools. +5. Recalculate `requiredBytes` after adding the view. Never truncate raw result + or Agent View to satisfy the MCP bound; return the existing explicit + transport-limit error. +6. Limit the projection to the seven typed query tools named in the design. + Do not change PR, legacy, task-context, or Agent Graph tools. +7. Add a compatibility test proving that removing `agentView` and the new + code-query digest from the envelope leaves the old raw result unchanged. +8. Add CLI/MCP parity fixtures: for an identical graph and request, + CLI `agent-json` equals MCP `agentView` byte-semantically after transport-only + fields are removed. + +### Compatibility gate + +Before committing the envelope change, inspect current documentation and any +typed consumer in the repository. If a shipped consumer rejects unknown +siblings in `compass.mcp.tool-result/1`, STOP. Introduce an opt-in v2 envelope +and add a migration plan instead of changing the v1 envelope silently. + +**Verify**: + +```bash +CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-7af7-agent-view \ + cargo test -p compass-mcp --test code_query_tools --locked +``` + +Expected: agent-readable text, Agent View/raw-result parity, protocol errors, +and transport-bound tests all pass. + +## Phase 6: Document, qualify, and prevent drift + +### Changes + +1. Document `compass.query.agent-view/1`, its status dimensions, fixed bounds, + raw-result relationship, and examples in the query/output references. +2. Update command help/reference for `agent-json` and state plainly that human + text may evolve while raw and Agent View JSON are versioned. +3. Update MCP documentation to define `agentView` as an optional separately + versioned sibling and to preserve the raw `result` as authoritative. +4. Update assistant assets so agents: + - read `RESULT`, `ANSWER`, and `CAVEATS` before details; + - never treat fallback candidates as an answer; + - follow exact next-action arguments rather than reconstructing a command; + - use raw/evidence output for an audit. +5. Add an install-tree drift test for the changed assistant guidance. +6. Add a `CHANGELOG.md` entry and `COMPATIBILITY.md` contract note. Update + `MIGRATION.md` only if an MCP envelope major or user action is required. +7. Run query qualification to prove ranking, no-answer behavior, path + direction, backend parity, and deterministic raw results did not change. +8. Mark Plan 025 `DONE` only after all targeted and workspace checks pass. + +**Verify**: + +```bash +CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-7af7-agent-view \ + cargo test -p compass-cli --test install_cli --locked +CARGO_TARGET_DIR=/Volumes/Workspace/crabbuild-target/compass-7af7-agent-view \ + python3 scripts/qualify_query_relevance.py +sh scripts/check_product_boundary.sh +``` + +Expected: assistant assets match, relevance thresholds/backend parity pass, and +the product boundary remains clean. + +## Test plan + +Use these existing tests as structural patterns: + +- `crates/compass-output/tests/pr_review.rs` for readable/full projection + parity, bounded omissions, and compact identifiers; +- `crates/compass-query/src/discovery_text.rs` tests for cursor binding, + injection-resistant text, pagination, and digest stability; +- `crates/compass-cli/tests/code_query_cli.rs` for typed/raw CLI contracts, + exact/no-match behavior, and end-to-end paths; +- `crates/compass-mcp/tests/code_query_tools.rs` for raw structured content, + store parity, semantic digest, protocol errors, and transport behavior. + +The new test suite must prove these cross-surface invariants: + +- every entity/relationship/path/basis ID exists in the raw result; +- no positive answer for no-match or ambiguous input; +- edge and path direction is never reversed by presentation; +- source and projection truncation remain distinct; +- coverage defaults to unknown, never complete; +- blocker caveats occur before graph detail in text; +- Agent View is deterministic across repeated and JSON/store executions; +- Agent View remains within all item/byte bounds; +- raw results do not change; +- discovery v2 cursors retain their semantic positions. + +## Done criteria + +- [ ] `compass.query.agent-view/1` is strict, validated, bounded, and documented. +- [ ] All seven typed MCP query tools return answer-first text from the shared renderer. +- [ ] MCP structured content retains the raw result and exposes the same Agent View as CLI. +- [ ] Typed CLI commands default to Agent View text and accept `--format agent-json`. +- [ ] Natural discovery has an answer-first fixed header without changing its v2 entry ledger. +- [ ] Raw `--format json` output remains semantically and order equivalent. +- [ ] No-match, ambiguity, direction mismatch, stale source, incomplete coverage, and both truncation kinds have regression tests. +- [ ] JSON/store backend parity and repeated-run determinism pass. +- [ ] `cargo fmt --all -- --check` passes. +- [ ] Focused Clippy and all targeted tests in the command table pass. +- [ ] Query relevance qualification passes unchanged thresholds. +- [ ] Workspace lib/bin Clippy and tests pass. +- [ ] `sh scripts/check_product_boundary.sh` passes. +- [ ] `git diff --check` passes and `git status --short` contains only in-scope changes. +- [ ] Documentation, compatibility, changelog, assistant assets, and plan status are updated. + +## STOP conditions + +Stop and report; do not improvise if: + +- producing an accurate headline requires re-running resolution or traversal in + `compass-output`; +- the view would need to modify `CodeQueryResponse` or + `DiscoveryQueryResponse` to work; +- a display rule would select the first ambiguous or fuzzy candidate; +- coverage would have to be labeled complete without direct completeness + evidence; +- the implementation needs a model, embeddings, credentials, or network; +- the raw CLI JSON or MCP `result` changes; +- discovery page entry ordering or identity must change; preserve cursor v2 or + stop for a separate compatibility design; +- a strict shipped MCP v1 consumer cannot accept the optional `agentView` + sibling; +- the mandatory status/identity/blocker prefix cannot fit the Agent View byte + bound; +- any test can pass only by weakening direction, provenance, ambiguity, + truncation, or unknown-major validation; +- `/Volumes/Workspace` is unavailable or the dedicated target directory is not + writable. + +## Maintenance notes + +- Any new `QueryDiagnosticCode` must add a deliberate caveat severity/effect + mapping or fail an exhaustiveness test. +- Any new typed query operation must add an invocation role mapping, headline + template, primary-result rule, CLI/MCP parity test, and documentation before + it can advertise Agent View support. +- Tool or command renames must update next-action drift tests. Do not leave + executable-looking stale actions in a versioned view. +- Agent View profile limits and ordering affect output digests and must be + reviewed as contract behavior. +- Plan 018 prompt templates should consume `RESULT`, `ANSWER`, `CAVEATS`, and + exact next actions after this plan lands; they should not replicate the + interpretation rules in prompt prose. +- A later real-repository agent workflow benchmark may evaluate task success, + tool calls, and context bytes. It is deliberately outside this output plan. diff --git a/advisor-plans/README.md b/advisor-plans/README.md index c2c578069..1815ec79f 100644 --- a/advisor-plans/README.md +++ b/advisor-plans/README.md @@ -1,6 +1,6 @@ # Compass enhancement advisor plans -Generated from deep product/code audits between 2026-07-23 and 2026-08-25. +Generated from deep product/code audits between 2026-07-23 and 2026-09-16. Upstream snapshots: @@ -96,6 +96,12 @@ adds semantic identities and labels, resolves cell-owned references before the normalization facts are projected back to graph-v1 resources, keeps table navigation out of architecture topology, and adds an independent quality gate. +Plan 025 makes code-query output answer-first for coding agents. It adds a +strict, bounded `compass.query.agent-view/1` projection over unchanged raw +query results, shares one deterministic renderer between CLI and MCP, exposes +ambiguity, evidence, coverage, and truncation before graph details, and +preserves discovery cursor version 2 by leaving its entry ledger intact. + ## Execution order and status | Plan | Title | Priority | Effort | Depends on | Status | @@ -124,6 +130,7 @@ navigation out of architecture topology, and adds an independent quality gate. | 022 | Add bounded, quality-gated OCR to document processing | P1 | XL | 006, 007, 008, 010 | IN PROGRESS | | 023 | Make Python framework graphs source-proven and production-qualified | P1 | XXL | —; final gate should consume 005 or equivalent | BLOCKED | | 024 | Harden Markdown graph-v1 intelligence | P1 | XL | 009; coordinate with 012 | IN PROGRESS | +| 025 | Make query output answer-first for coding agents | P1 | L | —; land before 018 when both are selected | DONE | Status values: `TODO`, `IN PROGRESS`, `DONE`, `BLOCKED`, or `REJECTED`. @@ -194,6 +201,11 @@ helper runtimes prerequisites for native document support. internal normalization representation only; the publisher resolves their evidence and downgrades them to established resource details before strict validation. Table navigation nodes remain public and searchable. +- Plan 025 is independently implementable and changes presentation rather than + query semantics. If Plan 018 is also selected, Plan 025 should land first so + MCP workflow prompts consume the typed Agent View instead of duplicating + interpretation rules. Plan 017 may later project ranked execution flows + through the same view after defining its own operation contract. ## Direction options not promoted to implementation plans diff --git a/advisor-plans/designs/025-agent-readable-query-output-design.md b/advisor-plans/designs/025-agent-readable-query-output-design.md new file mode 100644 index 000000000..019931d85 --- /dev/null +++ b/advisor-plans/designs/025-agent-readable-query-output-design.md @@ -0,0 +1,679 @@ +# Agent-readable query output design + +Status: implemented + +Date: 2026-09-16 + +Owner boundaries: `compass-query`, `compass-output`, `compass-cli`, and +`compass-mcp` + +Primary contract: `compass.query.agent-view/1` + +Implementation plan: [`../025-agent-readable-query-output.md`](../025-agent-readable-query-output.md) + +## Decision summary + +Compass will add a bounded, deterministic **Agent Query View** over the existing +typed query responses. The view presents the result state, one evidence-backed +headline, decisive caveats, primary entities, readable relationships and +paths, and exact next actions before low-level graph details. + +The Agent Query View is a projection, not a new query engine: + +```text +query request + | + v +compass-query + | + +--> compass.query/1 or compass.query.discovery/1 authoritative result + | + v +compass-output agent-query projector + | + +--> compass.query.agent-view/1 bounded agent contract + +--> answer-first text CLI and MCP content + `--> agentView in MCP transport alongside raw result +``` + +The raw query result remains authoritative and unchanged. The projection may +summarize or omit low-priority records under explicit bounds, but it may not +invent a target, relationship, path, confidence, completeness state, or source +location. + +## Problem + +Compass already returns source-grounded graph evidence, but the current output +requires an agent to reconstruct the answer shape: + +- typed MCP text contains only operation and node/edge/path counts; +- discovery text spends its first lines on seed, traversal, and scope metadata; +- nodes, relationships, paths, and diagnostics are separate collections; +- diagnostics that invalidate a conclusion can appear after the graph records; +- endpoint labels must be joined from node IDs by the consumer; +- the result gives no bounded, exact next action for ambiguity, truncation, or + deeper inspection. + +This is presentation friction rather than a retrieval defect. Changing ranking +or graph semantics would increase risk without addressing the main cost: an +agent spends context and tool calls translating a correct low-level result into +an actionable inspection result. + +## Goals + +1. Put the interpretation state before graph details. +2. Make decisive evidence understandable without joining arrays by ID. +3. Keep match resolution, relationship evidence, execution completeness, and + corpus coverage separate. +4. Make ambiguity, missing exact matches, direction mismatch, stale source, and + truncation impossible to overlook. +5. Provide exact, bounded follow-up actions without shell interpolation. +6. Preserve raw query schemas, ordering, evidence, and digests. +7. Produce byte-deterministic output for equivalent inputs. +8. Keep the projection local, credential-free, provider-free, and bounded. + +## Non-goals + +- No model-generated summary or answer. +- No new ranking, search, traversal, resolution, or graph facts. +- No automatic selection of an ambiguous or fuzzy candidate. +- No claim that absence proves nonexistence when graph coverage is unknown. +- No source excerpt copied into the compact view; exact source locations point + the agent to `task_context`, `explore_code`, or the raw evidence. +- No replacement of `compass.query/1`, `compass.query.discovery/1`, CompassQL, + or `compass.task-context/2`. +- No new discovery-cursor major solely because prose changes. +- No agent-workflow state machine or server-side tool execution. + +## Design principles + +### Answer first, audit second + +The first screenful must say whether the result is usable and why. Full +provenance remains available, but hashes, extractor details, and raw evidence +arrays do not precede the answer. + +### Separate meanings that agents commonly conflate + +The view never exposes one generic `confidence` field. It reports: + +- **result state**: what kind of response this is; +- **match state**: how the requested operand matched graph identity; +- **evidence state**: the weakest evidence supporting displayed graph facts; +- **source execution**: whether query execution hit a bound; +- **projection state**: whether the compact Agent View omitted retained facts; +- **coverage state**: whether incomplete graph coverage is known, otherwise + `unknown`. + +### Evidence references are lossless + +Every displayed entity, relationship, and path retains its full stable ID or a +reference to the raw record. Human text may shorten IDs only when it also says +where the complete value is available. Agent JSON always carries complete IDs. + +### Unavailability is a result + +An ambiguous, no-match, no-path, direction-mismatch, or stale-source condition +is rendered explicitly. It is never converted into an empty success or an +inferred answer. + +## Contract + +The new strict schema is `compass.query.agent-view/1`. + +```text +AgentQueryView + schema + request + operation + question? + operands[] { role, value } + status + resultState + matchState + evidenceState + sourceExecution + projection + coverage + answer + headline + basis[] { kind, id } + primaryResults[] + relationships[] + paths[] + caveats[] + nextActions[] + omissions + identity + rawSchema + graphIdentity + buildGenerationIdentity + sourceResultDigest + viewDigest + sourceTruncated + projectionTruncated +``` + +All serialized structs use camelCase and `deny_unknown_fields`. Unknown schema +majors fail explicitly. + +### Request + +`operation` is a closed enum: + +```text +discovery | search | callers | callees | impact | explore | node_trail +``` + +Operands are ordered and role-typed: + +```text +query | symbol | source | target | root +``` + +The request echoes bounded user data as data. It is not executable prose. + +### Status + +`resultState` is one of: + +| State | Meaning | +| --- | --- | +| `answered` | The operation produced an exact, structurally interpretable result, including a valid zero-count result. | +| `candidates` | Broad discovery produced ranked anchors and relationships, not a single exact answer. | +| `needs_resolution` | Multiple viable exact/fuzzy targets remain; no candidate was selected. | +| `no_match` | No exact match exists. Fallback candidates may still be displayed. | +| `no_path` | Exact endpoints resolved but no valid directed path was returned. | + +`matchState` is one of: + +```text +exact | fuzzy | ambiguous | none | not_applicable | unknown +``` + +`evidenceState` is one of: + +```text +exact | inferred | mixed | ambiguous | none +``` + +`sourceExecution` and `projection` are independently `complete` or `partial`. +`coverage` is `incomplete` only when the query result reports that fact; +otherwise it is `unknown`. Version 1 deliberately has no `complete` coverage +state because the current query response cannot prove complete corpus coverage. + +### Outcome precedence + +The projector applies this deterministic precedence: + +1. `ambiguous_match` or an ambiguous discovery seed -> `needs_resolution`; +2. `no_match` -> `no_match`, even when fallback candidates exist; +3. `direction_mismatch` -> `no_path` with a blocking caveat; +4. exact node-trail endpoints with zero paths -> `no_path`; +5. broad discovery -> `candidates`; +6. otherwise -> `answered`. + +Truncation does not replace the result state. It sets `sourceExecution` or +`projection` to `partial` and adds a caveat. This lets an agent distinguish +“answer unavailable” from “answer available but incomplete.” + +### Deterministic answer templates + +The projector uses closed templates by operation. It does not synthesize prose +from source code. + +Examples: + +```text +Found 2 exact candidates for "Target". +No exact match for "Targat"; 3 fallback candidates are shown. +Found 4 incoming call or route relationships for Fixture.Target. +Found 3 direct callees for Fixture.Caller. +Found 12 potentially affected nodes within depth 4. +Found a 3-hop directed path from Router to TokenVerifier. +No directed path reaches the exact target within the requested bounds. +Found 3 candidate anchors and 8 relationships for the question. +``` + +Every non-count noun in a headline comes from the request, an exact displayed +entity label, or a closed operation label. A headline's `basis` references the +raw nodes, edges, paths, or diagnostics that justify it. + +If the projector cannot identify an exact display subject without +re-resolving, it quotes the requested operand and omits a target ID. It never +runs a second resolver in the output layer. + +### Primary results + +An `AgentEntity` contains: + +```text +id, label, kind, roles, language?, framework?, source? +``` + +Primary selection is operation-specific and stable: + +- discovery: seed order, then stable node ID; +- search: `SearchHit` order joined to nodes; +- callers: exact target followed by unique incoming sources; +- callees: exact source followed by unique outgoing targets; +- impact: traversal root followed by path endpoints in path order; +- explore: retained path endpoints followed by remaining requested-result nodes; +- node trail: source and target from the best path, then alternative endpoints. + +If an exact target cannot be proven from the response, the projector omits that +role rather than selecting the first node. The plan must add fixtures for +zero-edge callers/callees, isolated explore seeds, and no-path trails before +finalizing these rules. + +### Relationships + +Relationships inline readable endpoints so an agent does not need a join: + +```json +{ + "id": "edge:router-auth", + "source": {"id": "node:router", "label": "Router"}, + "relation": "calls", + "target": {"id": "node:auth", "label": "AuthMiddleware"}, + "site": {"file": "src/routes.rs", "startLine": 18}, + "evidence": { + "confidence": "exact", + "resolution": "exact", + "layers": ["structural_graph"] + } +} +``` + +Direction always follows the published edge. Parallel relationships remain +separate. Missing endpoint records produce a caveat and use the stable ID as +the display label; they are not dropped silently. + +### Paths + +Paths inline ordered steps: + +```text +AgentPath + id + steps[] + from { id, label } + edgeId + relation + direction: forward | reverse + to { id, label } + site? + weakestResolution + weakestConfidence +``` + +`direction` describes whether the path step follows or opposes the published +edge. It prevents an impact path or compatibility traversal from looking like +a forward runtime call when it was traversed in reverse. + +### Caveats + +Caveats are sorted by severity, stable code, node ID, path, then statement. + +```text +blocker | warning | info +``` + +Initial diagnostic mapping: + +| Diagnostic | Severity | Required effect text | +| --- | --- | --- | +| `ambiguous_match` | blocker | Do not select a candidate automatically. | +| `no_match` | blocker | Fallback candidates are suggestions, not an exact answer. | +| `direction_mismatch` | blocker | A reverse-only connection is not a valid directed path. | +| `stale_source_digest` | blocker | Do not quote or edit the stale source excerpt. | +| `incomplete_coverage` | warning | Absence is not proof that the relationship does not exist. | +| `bounded_truncation` | warning | More retained facts may exist beyond the response bound. | +| `unresolved_handler` | warning | The framework target is unresolved. | +| `program_conflict` | warning | Structural and Program IR evidence disagree. | +| `program_orphan` | info | Program evidence could not join to a graph entity. | +| `program_unavailable` | info | Optional Program IR evidence was unavailable. | + +The original bounded diagnostic statement is retained as data after safe text +escaping. Blockers and warnings appear before result details in text output. + +### Next actions + +At most five actions are returned. Actions use argv arrays and JSON argument +objects; Compass never emits a shell command assembled from untrusted values. + +```text +AgentNextAction + kind + reason + cli? { argv[] } + mcp? { tool, arguments } +``` + +Initial action kinds: + +- `retry_with_exact_id` for each retained ambiguity candidate, bounded to three; +- `inspect_target` using `compass context explain` / `task_context` when exactly + one primary target is proven; +- `continue_result` when a discovery text cursor exists; +- `show_evidence` when compact text hides provenance; +- `narrow_query` when projection or source execution is partial and no cursor + is available. + +Actions are suggestions, not server-side execution. Their tool and command +names are covered by drift tests against the actual CLI/MCP registries. + +### Identity and digests + +The view records: + +- the selected immutable graph identity; +- build generation identity; +- the raw result schema; +- a source-result digest; +- a digest of the Agent View excluding `viewDigest` itself. + +Discovery reuses `discovery_response_digest`. `compass-query` adds a canonical +`code_query_response_digest` that clones and stably sorts a response before +serialization. It includes the semantic result and limits, and excludes no +facts because `CodeQueryResponse` contains no timing fields. + +The Agent View digest does not become a graph or history identity. It proves +only that the compact projection was not mutated. + +## Bounds and omission policy + +Version 1 uses a fixed presentation profile: + +| Dimension | Default/hard limit | +| --- | ---: | +| Primary results | 12 | +| Relationships | 24 | +| Paths | 5 | +| Caveats | 16 | +| Next actions | 5 | +| Serialized Agent View | 256 KiB | +| Rendered text | 64 KiB | +| One rendered scalar | 512 Unicode scalar values | + +The raw query response retains its existing independent limits. Projection +limits never mutate the raw result or source-result digest. + +Priority under the Agent View byte bound is: + +1. schema, request, status, identities, and blocker caveats; +2. answer and its basis; +3. primary results; +4. warning caveats; +5. best path; +6. relationships; +7. alternative paths; +8. informational caveats; +9. next actions other than ambiguity resolution. + +The projector removes complete low-priority records until the view fits. It +never cuts a JSON scalar or emits invalid JSON. Every removal increments an +exact omission counter and sets `projectionTruncated=true`. If the mandatory +prefix alone exceeds 256 KiB, projection fails explicitly. + +## Text projection + +The default text order is: + +```text +RESULT +ANSWER +CAVEATS +PRIMARY RESULTS +PATHS +RELATIONSHIPS +NEXT ACTIONS +DETAILS +``` + +Example: + +```text +RESULT: answered +Match: exact +Evidence: exact +Execution: complete within requested bounds +Coverage: unknown + +ANSWER +Found 2 incoming call or route relationships for Fixture.Target. + +CAVEATS +- Coverage is unknown; absence is not proof of no additional caller. + +PRIMARY RESULTS +- Fixture.Target [function] src/lib.rs:20 + id: n:target + +RELATIONSHIPS +- Fixture.Caller --calls--> Fixture.Target + src/lib.rs:10 · exact + +NEXT ACTIONS +- Inspect the exact target: + MCP task_context {"intent":"explain","target":"n:target"} + +DETAILS +2 nodes · 1 relationship · 0 paths +Full provenance is available in the raw JSON/evidence view. +``` + +For `no_match` and `needs_resolution`, `ANSWER` explains why no exact answer is +available. It must never turn fallback candidates into a positive answer. + +All headings and control-sensitive text are renderer-owned. Repository labels, +paths, diagnostic messages, and operands are escaped so they cannot forge a +heading, pagination footer, terminal control sequence, or bidirectional text. + +## CLI integration + +The typed command family accepts: + +```text +--format text default Agent View text +--format json unchanged raw compass.query/1 or discovery response +--format agent-json strict compass.query.agent-view/1 +``` + +`--format agent-json` is incompatible with text-only `--cursor`, +`--text-budget`, and `--evidence`. The existing `--result-envelope` remains a +raw discovery JSON feature and does not wrap Agent View JSON. + +Natural discovery retains the current `compass.query.discovery-text-page/2` +cursor. The implementation changes only the fixed answer-first header and +keeps the ordered entry ledger (`section`, `item`, `offset`) exactly intact. +Therefore an existing v2 cursor still identifies the same semantic next entry. + +This does not reverse the crate dependency: `compass-output` renders the +validated, escaped Agent View header lines, while `compass-query` retains page +selection, cursor validation, the entry ledger, and the pagination footer. A +new `render_discovery_text_page_with_prefix` entry point accepts those fixed +prefix lines. The existing `render_discovery_text_page` remains a compatibility +wrapper with its current prefix, so library callers are not forced onto the new +presentation. + +If implementation requires reordering, inserting, or removing ledger entries, +it must stop for compatibility review. It may introduce a separate Agent View +pagination contract, but it must not silently reinterpret or gratuitously +advance discovery text cursor version 2. + +## MCP integration + +For `search_symbols`, `get_callers`, `get_callees`, `get_impact`, +`explore_code`, `get_node`, and typed `query_graph`: + +- `content` contains the bounded Agent View text instead of a count-only line; +- `structuredContent.result` remains the unchanged raw typed result; +- `structuredContent.agentView` contains `compass.query.agent-view/1`; +- `structuredContent.semanticResultDigest` is present for both code-query and + discovery results; +- `transportTruncation` keeps its existing meaning and is calculated after the + view is inserted. + +`compass.mcp.tool-result/1` remains the transport schema because existing +fields retain their meaning and `agentView` is an optional, separately +versioned projection. The MCP reference must explicitly document that v1 +transport consumers ignore unknown optional sibling projections while still +rejecting unknown `agentView` majors they choose to consume. + +If compatibility evidence shows that shipped consumers require a closed MCP +envelope, the implementation must instead introduce an opt-in +`compass.mcp.tool-result/2`; it must not silently break a strict v1 reader. + +Legacy compatibility tools, PR tools, Agent Graph tools, and `task_context` are +out of scope for version 1. + +## Ownership + +### `compass-query` + +- remains authoritative for retrieval, resolution, diagnostics, and source + result digests; +- adds only the canonical digest helper for `CodeQueryResponse`; +- owns the prefix-aware discovery page seam because cursor and entry-ledger + semantics already live here; +- does not construct Agent View text or select display priorities. + +### `compass-output` + +- owns `AgentQueryView`, validation, projection limits, deterministic summary + templates, safe text rendering, and view digest; +- never opens a graph, runs search, or resolves an operand; +- follows the existing PR-review renderer pattern: full machine IDs, compact + readable text, explicit omissions, and bounded output. + +### `compass-cli` + +- captures the invocation operands and graph identities; +- calls the shared projector and renderer; +- preserves raw JSON behavior and exit codes. + +### `compass-mcp` + +- captures the same invocation context; +- uses the shared view for text and structured projection; +- keeps tool validation, raw results, protocol errors, and transport bounds. + +## Compatibility + +Unchanged: + +- `compass.query/1`; +- `compass.query.discovery/1`; +- `compass.query.discovery-result/1`; +- graph and history schemas; +- query ranking, resolution, diagnostics, and limits; +- current raw CLI JSON; +- the discovery cursor's semantic position contract. + +Additive: + +- `compass.query.agent-view/1`; +- CLI `--format agent-json`; +- optional MCP `agentView`; +- a code-query semantic result digest; +- answer-first human/MCP text. + +Human text is documented as presentation, not a durable parser contract. +Nevertheless, release notes must call out the new headings and tell automation +to use raw JSON or Agent View JSON. + +## Validation and invariants + +`AgentQueryView::validate` rejects: + +- an unknown schema; +- empty graph/generation/result/view identities; +- an invalid digest; +- a relationship or path endpoint absent from both the raw result and the + view's retained entity references; +- a basis reference that does not exist in the raw result; +- `answered` combined with `ambiguous` or `none` match state; +- `no_match` without a no-match caveat; +- `needs_resolution` without at least two retained candidates or an explicit + ambiguity omission; +- `sourceExecution=complete` when the raw result is truncated; +- `projection=complete` when any projection omission is nonzero; +- a `task_context` next action without one proven exact target; +- output above its byte or item bounds. + +Projection tests must also prove that every statement is a closed template +whose interpolated values are request or raw-result data. + +## Test matrix + +At minimum, cover: + +1. exact search; +2. no exact match with fuzzy candidates; +3. ambiguous duplicate names; +4. exact target with zero callers; +5. exact callers/callees with mixed evidence; +6. impact with reverse traversal steps; +7. exact node trail and direction mismatch; +8. broad discovery with several seeds; +9. source execution truncation; +10. projection truncation with exact omission counts; +11. incomplete coverage and stale-source blocker placement; +12. control characters, bidi controls, and forged heading/footer strings; +13. identical JSON/store results producing identical Agent Views; +14. CLI `agent-json` and MCP `agentView` semantic parity; +15. raw CLI JSON and MCP `result` remaining unchanged; +16. current discovery cursor v2 continuing to the same next entry. + +## Rollout + +1. Land the contract, validator, projector, and output tests without wiring a + public adapter. +2. Switch typed CLI text and add `agent-json`; retain raw JSON. +3. Replace MCP count-only text and add the optional structured projection. +4. Update assistant guidance, command/MCP/output references, compatibility, + and changelog. +5. Run query relevance and backend-parity gates to prove presentation work did + not alter retrieval. + +## Alternatives considered + +### Teach agents to join the existing arrays + +Rejected. It repeats orchestration in every client, consumes context, and makes +critical diagnostics easy to miss. + +### Replace raw query JSON with Agent View JSON + +Rejected. Raw results are the audit contract and contain evidence the compact +view intentionally omits. + +### Generate natural-language answers with a model + +Rejected. It violates local-first operation, adds nondeterminism and +credentials, and can turn uncertain evidence into plausible prose. + +### Put the projector in the CLI or MCP crate + +Rejected. The two surfaces would drift and reusable presentation behavior +would live above its ownership boundary. + +### Change discovery pagination to match the new visual order + +Rejected for version 1. The answer-first fixed header provides the usability +gain while preserving the current cursor's semantic entry positions. + +## Success criteria + +- A tool consumer can determine outcome, ambiguity, truncation, and coverage + without joining raw arrays. +- The first MCP text content contains the result state and headline, not only + counts. +- Every positive headline has exact raw-result basis references. +- No-match and ambiguous results never publish a positive answer. +- CLI Agent JSON and MCP Agent View are byte-equivalent after transport-only + fields are removed. +- Raw JSON responses remain semantically and byte-order equivalent. +- Equivalent JSON/store queries produce identical Agent Views. +- All output remains inside named bounds and deterministic across repeats. diff --git a/crates/compass-cli/assets/compass-integrations/agents-md.md b/crates/compass-cli/assets/compass-integrations/agents-md.md index 0209c8c43..0d8501824 100644 --- a/crates/compass-cli/assets/compass-integrations/agents-md.md +++ b/crates/compass-cli/assets/compass-integrations/agents-md.md @@ -18,6 +18,11 @@ Daily workflow: focused query. - Inspect direction, ambiguity, graph completeness, domain truncation, and the final Pagination line before relying on a result. +- Prefer `--format agent-json` for agent-controlled follow-up. Read `status`, + `answer`, and `caveats` first; `no_match`, `needs_resolution`, and `no_path` + are non-answers, and fallback candidates are suggestions only. Check source + and projection truncation before claiming completeness, then follow exact + `nextActions` arguments. Use raw `--format json` for full provenance. - When a seed is ambiguous, repeat the query with the exact node ID. - Follow `next=` with the unchanged question and options plus `--cursor ` when the requested scope must be exhaustive; stop at diff --git a/crates/compass-cli/assets/compass-integrations/antigravity-rules.md b/crates/compass-cli/assets/compass-integrations/antigravity-rules.md index 0209c8c43..0d8501824 100644 --- a/crates/compass-cli/assets/compass-integrations/antigravity-rules.md +++ b/crates/compass-cli/assets/compass-integrations/antigravity-rules.md @@ -18,6 +18,11 @@ Daily workflow: focused query. - Inspect direction, ambiguity, graph completeness, domain truncation, and the final Pagination line before relying on a result. +- Prefer `--format agent-json` for agent-controlled follow-up. Read `status`, + `answer`, and `caveats` first; `no_match`, `needs_resolution`, and `no_path` + are non-answers, and fallback candidates are suggestions only. Check source + and projection truncation before claiming completeness, then follow exact + `nextActions` arguments. Use raw `--format json` for full provenance. - When a seed is ambiguous, repeat the query with the exact node ID. - Follow `next=` with the unchanged question and options plus `--cursor ` when the requested scope must be exhaustive; stop at diff --git a/crates/compass-cli/assets/compass-integrations/claude-md.md b/crates/compass-cli/assets/compass-integrations/claude-md.md index 0209c8c43..0d8501824 100644 --- a/crates/compass-cli/assets/compass-integrations/claude-md.md +++ b/crates/compass-cli/assets/compass-integrations/claude-md.md @@ -18,6 +18,11 @@ Daily workflow: focused query. - Inspect direction, ambiguity, graph completeness, domain truncation, and the final Pagination line before relying on a result. +- Prefer `--format agent-json` for agent-controlled follow-up. Read `status`, + `answer`, and `caveats` first; `no_match`, `needs_resolution`, and `no_path` + are non-answers, and fallback candidates are suggestions only. Check source + and projection truncation before claiming completeness, then follow exact + `nextActions` arguments. Use raw `--format json` for full provenance. - When a seed is ambiguous, repeat the query with the exact node ID. - Follow `next=` with the unchanged question and options plus `--cursor ` when the requested scope must be exhaustive; stop at diff --git a/crates/compass-cli/assets/compass-integrations/gemini-md.md b/crates/compass-cli/assets/compass-integrations/gemini-md.md index 0209c8c43..0d8501824 100644 --- a/crates/compass-cli/assets/compass-integrations/gemini-md.md +++ b/crates/compass-cli/assets/compass-integrations/gemini-md.md @@ -18,6 +18,11 @@ Daily workflow: focused query. - Inspect direction, ambiguity, graph completeness, domain truncation, and the final Pagination line before relying on a result. +- Prefer `--format agent-json` for agent-controlled follow-up. Read `status`, + `answer`, and `caveats` first; `no_match`, `needs_resolution`, and `no_path` + are non-answers, and fallback candidates are suggestions only. Check source + and projection truncation before claiming completeness, then follow exact + `nextActions` arguments. Use raw `--format json` for full provenance. - When a seed is ambiguous, repeat the query with the exact node ID. - Follow `next=` with the unchanged question and options plus `--cursor ` when the requested scope must be exhaustive; stop at diff --git a/crates/compass-cli/assets/compass-integrations/kilo-plugin.js b/crates/compass-cli/assets/compass-integrations/kilo-plugin.js index 78474d90e..e918b43e3 100644 --- a/crates/compass-cli/assets/compass-integrations/kilo-plugin.js +++ b/crates/compass-cli/assets/compass-integrations/kilo-plugin.js @@ -10,7 +10,7 @@ const server = async ({ directory }) => { if (!existsSync(join(directory, "compass-out", "graph.json"))) return; if (input.tool === "bash") { output.args.command = - 'echo "[compass] Focused task: query first. Broad first session: read only Agent Orientation at the start of GRAPH_REPORT.md, then query. Inspect direction, ambiguity, completeness, domain truncation, pagination, and minimal cited source. Keep compass watch running or update after edits." ; ' + + 'echo "[compass] Focused task: query first. Broad first session: read only Agent Orientation at the start of GRAPH_REPORT.md, then query. Read RESULT, ANSWER, and CAVEATS before details; inspect direction, ambiguity, completeness, domain truncation, pagination, and minimal cited source. Keep compass watch running or update after edits." ; ' + output.args.command; reminded = true; } diff --git a/crates/compass-cli/assets/compass-integrations/kiro-steering.md b/crates/compass-cli/assets/compass-integrations/kiro-steering.md index 0209c8c43..0d8501824 100644 --- a/crates/compass-cli/assets/compass-integrations/kiro-steering.md +++ b/crates/compass-cli/assets/compass-integrations/kiro-steering.md @@ -18,6 +18,11 @@ Daily workflow: focused query. - Inspect direction, ambiguity, graph completeness, domain truncation, and the final Pagination line before relying on a result. +- Prefer `--format agent-json` for agent-controlled follow-up. Read `status`, + `answer`, and `caveats` first; `no_match`, `needs_resolution`, and `no_path` + are non-answers, and fallback candidates are suggestions only. Check source + and projection truncation before claiming completeness, then follow exact + `nextActions` arguments. Use raw `--format json` for full provenance. - When a seed is ambiguous, repeat the query with the exact node ID. - Follow `next=` with the unchanged question and options plus `--cursor ` when the requested scope must be exhaustive; stop at diff --git a/crates/compass-cli/assets/compass-integrations/opencode-plugin.js b/crates/compass-cli/assets/compass-integrations/opencode-plugin.js index 6700842b8..4315c313a 100644 --- a/crates/compass-cli/assets/compass-integrations/opencode-plugin.js +++ b/crates/compass-cli/assets/compass-integrations/opencode-plugin.js @@ -10,7 +10,7 @@ export const CompassPlugin = async ({ directory }) => { if (!existsSync(join(directory, "compass-out", "graph.json"))) return; if (input.tool === "bash") { output.args.command = - 'echo "[compass] Focused task: query first. Broad first session: read only Agent Orientation at the start of GRAPH_REPORT.md, then query. Inspect direction, ambiguity, completeness, domain truncation, pagination, and minimal cited source. Keep compass watch running or update after edits." ; ' + + 'echo "[compass] Focused task: query first. Broad first session: read only Agent Orientation at the start of GRAPH_REPORT.md, then query. Read RESULT, ANSWER, and CAVEATS before details; inspect direction, ambiguity, completeness, domain truncation, pagination, and minimal cited source. Keep compass watch running or update after edits." ; ' + output.args.command; reminded = true; } diff --git a/crates/compass-cli/assets/compass-integrations/vscode-instructions.md b/crates/compass-cli/assets/compass-integrations/vscode-instructions.md index 0209c8c43..0d8501824 100644 --- a/crates/compass-cli/assets/compass-integrations/vscode-instructions.md +++ b/crates/compass-cli/assets/compass-integrations/vscode-instructions.md @@ -18,6 +18,11 @@ Daily workflow: focused query. - Inspect direction, ambiguity, graph completeness, domain truncation, and the final Pagination line before relying on a result. +- Prefer `--format agent-json` for agent-controlled follow-up. Read `status`, + `answer`, and `caveats` first; `no_match`, `needs_resolution`, and `no_path` + are non-answers, and fallback candidates are suggestions only. Check source + and projection truncation before claiming completeness, then follow exact + `nextActions` arguments. Use raw `--format json` for full provenance. - When a seed is ambiguous, repeat the query with the exact node ID. - Follow `next=` with the unchanged question and options plus `--cursor ` when the requested scope must be exhaustive; stop at diff --git a/crates/compass-cli/assets/compass-skill/references/query.md b/crates/compass-cli/assets/compass-skill/references/query.md index df7f96e99..2b726f0f6 100644 --- a/crates/compass-cli/assets/compass-skill/references/query.md +++ b/crates/compass-cli/assets/compass-skill/references/query.md @@ -55,6 +55,23 @@ compass query "authentication flow" --at HEAD~20 `--graph` and `--at` are mutually exclusive. +For agent-controlled follow-up, use the versioned Agent View projection: + +```bash +compass query "who calls PaymentGateway.charge?" --format agent-json +compass callers PaymentGateway.charge --format agent-json +``` + +Read `status.resultState`, `answer`, and `caveats` before `primaryResults`, +`paths`, or `relationships`. `no_match`, `needs_resolution`, and `no_path` +must remain non-answers; fallback candidates are leads only. Check both +`sourceExecution` and `projection` for truncation and treat `coverage: +unknown` as unknown rather than complete. Use exact `nextActions` arguments and +IDs rather than rebuilding shell commands. Agent View JSON is +`compass.query.agent-view/1`; raw `--format json` remains the full audit +result, and human text headings are presentation rather than a parser +contract. + ## Focused graph operations ```bash diff --git a/crates/compass-cli/src/code_query_commands.rs b/crates/compass-cli/src/code_query_commands.rs index 90d1a4499..f4933a1f3 100644 --- a/crates/compass-cli/src/code_query_commands.rs +++ b/crates/compass-cli/src/code_query_commands.rs @@ -1,10 +1,12 @@ -use std::collections::BTreeMap; use std::path::PathBuf; use compass_model::query_contract::{ CallRequest, CodeQueryLimits, CodeQueryResponse, ExploreRequest, ImpactRequest, NodeTrailRequest, SearchRequest, }; +use compass_output::{ + AgentOperandRole, AgentQueryContext, build_code_query_view, render_agent_query_text, +}; use compass_query::{ EngineSelection, NaturalQueryRequest, open_with_engine, open_with_verified_document, }; @@ -12,25 +14,55 @@ use compass_query::{ use crate::Outcome; pub(crate) fn command(operation: &str, args: &[String]) -> Outcome { + let format = option(args, "--format").unwrap_or("text"); + if format == "agent-json" + && args.iter().any(|arg| { + matches!( + arg.as_str(), + "--cursor" | "--text-budget" | "--evidence" | "--result-envelope" + ) || arg.starts_with("--cursor=") + || arg.starts_with("--text-budget=") + }) + { + return Outcome::failure( + "error: --cursor, --text-budget, --evidence, and --result-envelope are text-only and cannot be used with --format agent-json".to_owned(), + ); + } match execute(operation, args) { - Ok(response) => { - let format = option(args, "--format").unwrap_or("text"); + Ok(execution) => { if format == "json" { - match serde_json::to_string_pretty(&response) { + match serde_json::to_string_pretty(&execution.response) { + Ok(json) => Outcome::success(json), + Err(error) => Outcome::failure(format!("error: {error}")), + } + } else if format == "agent-json" { + match build_code_query_view(&execution.response, execution.context) + .and_then(|view| serde_json::to_string_pretty(&view).map_err(Into::into)) + { Ok(json) => Outcome::success(json), Err(error) => Outcome::failure(format!("error: {error}")), } } else if format == "text" { - Outcome::success(render_text(&response)) + match build_code_query_view(&execution.response, execution.context) + .and_then(|view| render_agent_query_text(&view)) + { + Ok(text) => Outcome::success(text), + Err(error) => Outcome::failure(format!("error: {error}")), + } } else { - Outcome::failure("error: --format must be json or text".to_owned()) + Outcome::failure("error: --format must be json, agent-json, or text".to_owned()) } } Err(error) => Outcome::failure(format!("error: {error}")), } } -fn execute(operation: &str, args: &[String]) -> Result { +struct QueryExecution { + response: CodeQueryResponse, + context: AgentQueryContext, +} + +fn execute(operation: &str, args: &[String]) -> Result { let positional = positional(args); let graph_option = option(args, "--graph"); let revision = option(args, "--at"); @@ -102,46 +134,109 @@ fn execute(operation: &str, args: &[String]) -> Result engine.query_natural(NaturalQueryRequest { - question: required(&positional, 0, "ask ")?.to_owned(), - include_heuristic: args.iter().any(|arg| arg == "--include-heuristic"), - limits, - }), - "search" => engine.search(SearchRequest { - query: required(&positional, 0, "search ")?.to_owned(), - limits, - }), - "callers" => engine.callers(CallRequest { - symbol: required(&positional, 0, "callers ")?.to_owned(), - include_heuristic: args.iter().any(|arg| arg == "--include-heuristic"), - limits, - }), - "callees" => engine.callees(CallRequest { - symbol: required(&positional, 0, "callees ")?.to_owned(), - include_heuristic: args.iter().any(|arg| arg == "--include-heuristic"), - limits, - }), - "impact" => engine.impact(ImpactRequest { - symbol: required(&positional, 0, "impact ")?.to_owned(), - include_heuristic: args.iter().any(|arg| arg == "--include-heuristic"), - limits, - }), - "explore" => engine.explore(ExploreRequest { - symbols: positional, - root: option(args, "--root").unwrap_or_default().to_owned(), - include_heuristic: args.iter().any(|arg| arg == "--include-heuristic"), - limits, - }), - "node" => engine.node_trail(NodeTrailRequest { - source: required(&positional, 0, "node ")?.to_owned(), - target: required(&positional, 1, "node ")?.to_owned(), - include_heuristic: args.iter().any(|arg| arg == "--include-heuristic"), - limits, - }), + let include_heuristic = args.iter().any(|arg| arg == "--include-heuristic"); + let (response, question, operands) = match operation { + "ask" => { + let question = required(&positional, 0, "ask ")?.to_owned(); + let response = engine + .query_natural(NaturalQueryRequest { + question: question.clone(), + include_heuristic, + limits, + }) + .map_err(|error| error.to_string())?; + ( + response, + Some(question.clone()), + vec![(AgentOperandRole::Query, question)], + ) + } + "search" => { + let query = required(&positional, 0, "search ")?.to_owned(); + let response = engine + .search(SearchRequest { + query: query.clone(), + limits, + }) + .map_err(|error| error.to_string())?; + (response, None, vec![(AgentOperandRole::Query, query)]) + } + "callers" | "callees" | "impact" => { + let symbol = required(&positional, 0, "")?.to_owned(); + let response = match operation { + "callers" => engine.callers(CallRequest { + symbol: symbol.clone(), + include_heuristic, + limits, + }), + "callees" => engine.callees(CallRequest { + symbol: symbol.clone(), + include_heuristic, + limits, + }), + "impact" => engine.impact(ImpactRequest { + symbol: symbol.clone(), + include_heuristic, + limits, + }), + _ => unreachable!(), + } + .map_err(|error| error.to_string())?; + (response, None, vec![(AgentOperandRole::Symbol, symbol)]) + } + "explore" => { + let symbols = positional.clone(); + let response = engine + .explore(ExploreRequest { + symbols: symbols.clone(), + root: option(args, "--root").unwrap_or_default().to_owned(), + include_heuristic, + limits, + }) + .map_err(|error| error.to_string())?; + let mut operands = symbols + .into_iter() + .map(|symbol| (AgentOperandRole::Symbol, symbol)) + .collect::>(); + if let Some(root) = option(args, "--root").filter(|root| !root.is_empty()) { + operands.push((AgentOperandRole::Root, root.to_owned())); + } + (response, None, operands) + } + "node" => { + let source = required(&positional, 0, "node ")?.to_owned(); + let target = required(&positional, 1, "node ")?.to_owned(); + let response = engine + .node_trail(NodeTrailRequest { + source: source.clone(), + target: target.clone(), + include_heuristic, + limits, + }) + .map_err(|error| error.to_string())?; + ( + response, + None, + vec![ + (AgentOperandRole::Source, source), + (AgentOperandRole::Target, target), + ], + ) + } _ => unreachable!(), + }; + let mut context = AgentQueryContext::new( + response.operation.into(), + engine.graph_identity().to_owned(), + engine.build_generation_identity().to_owned(), + ); + if let Some(question) = question { + context = context.with_question(question); + } + for (role, value) in operands { + context = context.with_operand(role, value); } - .map_err(|error| error.to_string()) + Ok(QueryExecution { response, context }) } fn resolve_snapshot_artifact(path: PathBuf) -> Result { @@ -222,91 +317,3 @@ fn required<'a>(values: &'a [String], index: usize, usage: &str) -> Result<&'a s .map(String::as_str) .ok_or_else(|| format!("usage: compass {usage} [OPTIONS]")) } - -fn render_text(response: &CodeQueryResponse) -> String { - let no_match = response.diagnostics.iter().find(|diagnostic| { - diagnostic.code == compass_model::query_contract::QueryDiagnosticCode::NoMatch - }); - let mut lines = Vec::new(); - if let Some(diagnostic) = no_match { - lines.push("match_confidence: none".to_owned()); - lines.push(diagnostic.message.clone()); - } else if !response.nodes.is_empty() { - lines.push("match_confidence: exact".to_owned()); - } - lines.push(format!( - "{:?}: {} node(s), {} edge(s), {} path(s)", - response.operation, - response.nodes.len(), - response.edges.len(), - response.paths.len() - )); - lines.extend(response.nodes.iter().map(|node| { - format!( - "{} [{}] {}", - node.qualified_name, - node.kind.as_str(), - node.source - .as_ref() - .map(|source| format!("{}:{}", source.file, source.start_line)) - .unwrap_or_default() - ) - })); - let node_labels = response - .nodes - .iter() - .map(|node| (node.id.as_str(), node.qualified_name.as_str())) - .collect::>(); - let edges = response - .edges - .iter() - .map(|edge| (edge.id.as_str(), edge)) - .collect::>(); - for (index, path) in response.paths.iter().enumerate() { - if let Some(target) = path.node_ids.last() { - lines.push(format!("Target resolved: {target}")); - } - let mut segments = path - .node_ids - .first() - .map(|node| { - node_labels - .get(node.as_str()) - .copied() - .unwrap_or(node.as_str()) - .to_owned() - }) - .into_iter() - .collect::>(); - for (edge_id, nodes) in path.edge_ids.iter().zip(path.node_ids.windows(2)) { - let Some(edge) = edges.get(edge_id.as_str()) else { - continue; - }; - let right = node_labels - .get(nodes[1].as_str()) - .copied() - .unwrap_or(nodes[1].as_str()); - if edge.source == nodes[0] && edge.target == nodes[1] { - segments.push(format!("--{}--> {right}", edge.kind.as_str())); - } else { - segments.push(format!("<--{}-- {right}", edge.kind.as_str())); - } - } - lines.push(format!( - "{} path (weighted, {} hops): {}", - if index == 0 { "Best" } else { "Alternative" }, - path.edge_ids.len(), - segments.join(" ") - )); - } - lines.extend( - response - .diagnostics - .iter() - .filter(|diagnostic| { - diagnostic.code != compass_model::query_contract::QueryDiagnosticCode::NoMatch - }) - .map(|diagnostic| format!("! {:?}: {}", diagnostic.code, diagnostic.message)), - ); - lines.join("\n") -} diff --git a/crates/compass-cli/src/help.rs b/crates/compass-cli/src/help.rs index 5f9f6b2c5..bed65a6d6 100644 --- a/crates/compass-cli/src/help.rs +++ b/crates/compass-cli/src/help.rs @@ -1082,12 +1082,24 @@ fn render_page(page: &Page, style: HelpStyle) -> String { let _ = output.pop(); } let details = add_help_option(page.details); - let details = if page.path == "query" { + let details = if matches!( + page.path, + "ask" | "search" | "callers" | "callees" | "impact" | "explore" | "node" + ) { + format!( + "{}\n\nAgent View:\n --format agent-json emits the strict compass.query.agent-view/1 projection.\n --format text is answer-first and bounded; --format json remains the raw compass.query/1 result.", + details.replace("--format ", "--format ") + ) + } else if page.path == "query" { details .replace( "Query an exact immutable realization; conflicts with --graph", "Resolve REV once to an immutable typed realization; conflicts with --graph", ) + .replace( + "--format Discovery output", + "--format Discovery output", + ) .replace( "default/hard maximum: 500", "default: 64; hard maximum: 500", diff --git a/crates/compass-cli/src/lib.rs b/crates/compass-cli/src/lib.rs index 8d2902394..dd4b26335 100644 --- a/crates/compass-cli/src/lib.rs +++ b/crates/compass-cli/src/lib.rs @@ -67,14 +67,16 @@ use compass_model::query_contract::{ DiscoveryQueryResponse, DiscoveryScope, DiscoveryScopeKind, DiscoveryTraversal, ImpactRequest, }; use compass_output::{ - AffectedLensOptions, AgentOrientation, ArchitectureOverlay, ArchitectureOverlayGroup, - ArchitectureProjectionInput, ArchitectureProjectionOptions, ArtifactLens, CallflowOptions, - CallflowSection, CanvasOptions, HtmlOptions, ObsidianOptions, SourceNavigation, SvgOptions, - TreeOptions, WikiOptions, WorkbenchCoverage, WorkbenchCoverageStatus, WorkbenchModel, - WorkbenchView, WorkbenchViewContent, affected_lens_view_model, artifact_lens_view_model, - export_obsidian, export_wiki, graph_artifact_identity, graph_community_view_model_document, - graph_view_model_bundle_document, graph_view_model_document, node_filenames, - project_architecture, render_orientation_json, validate_orientation_graph_identity, + AffectedLensOptions, AgentOperandRole, AgentOperation, AgentOrientation, AgentQueryContext, + ArchitectureOverlay, ArchitectureOverlayGroup, ArchitectureProjectionInput, + ArchitectureProjectionOptions, ArtifactLens, CallflowOptions, CallflowSection, CanvasOptions, + HtmlOptions, ObsidianOptions, SourceNavigation, SvgOptions, TreeOptions, WikiOptions, + WorkbenchCoverage, WorkbenchCoverageStatus, WorkbenchModel, WorkbenchView, + WorkbenchViewContent, affected_lens_view_model, artifact_lens_view_model, + build_discovery_query_view, export_obsidian, export_wiki, graph_artifact_identity, + graph_community_view_model_document, graph_view_model_bundle_document, + graph_view_model_document, node_filenames, project_architecture, + render_agent_query_header_lines, render_orientation_json, validate_orientation_graph_identity, write_callflow_html, write_canvas, write_cypher, write_graphml, write_svg, write_tree_html, write_workbench_html_with_source_navigation, }; @@ -83,7 +85,7 @@ use compass_query::{ DEFAULT_AFFECTED_RELATIONS, DEFAULT_DISCOVERY_TEXT_TOKEN_BUDGET, DEFAULT_PATH_DEPTH_LIMIT, DEFAULT_TEXT_TOKEN_BUDGET, DiscoveryTextPageOptions, TextPageOptions, TraversalMode, discovery_request_digest, format_affected, format_benchmark, open as open_code_query, - open_with_verified_document, query_graph_text_page, render_discovery_text_page, + open_with_verified_document, query_graph_text_page, render_discovery_text_page_with_prefix, render_explanation_page, render_shortest_path_with_limit, run_benchmark, }; use compass_semantic::{ @@ -5442,10 +5444,11 @@ pub(crate) fn command_natural_query(frontend: Frontend, args: &[String]) -> Outc ); } if !legacy_requested { - if discovery_format == "json" && (discovery_text_pagination_requested || discovery_evidence) + if matches!(discovery_format.as_str(), "json" | "agent-json") + && (discovery_text_pagination_requested || discovery_evidence) { return Outcome::failure( - "error: --cursor, --text-budget, and --evidence are text-only and cannot be used with --format json" + "error: --cursor, --text-budget, and --evidence are text-only and cannot be used with --format json or agent-json" .to_owned(), ); } @@ -5522,8 +5525,10 @@ fn apply_discovery_option( } "--scope" => scope.push(parse_discovery_scope(value)?), "--format" => { - if !matches!(value, "text" | "json") { - return Err("--format must be text or json for discovery queries".to_owned()); + if !matches!(value, "text" | "json" | "agent-json") { + return Err( + "--format must be text, json, or agent-json for discovery queries".to_owned(), + ); } *format = value.to_owned(); } @@ -5624,13 +5629,52 @@ fn command_discovery_query( Ok(output) => Outcome::success(output), Err(error) => Outcome::failure(format!("error: {error}")), } + } else if format == "agent-json" { + let context = AgentQueryContext::new( + AgentOperation::Discovery, + execution.graph_digest.clone(), + execution.graph_identity.clone(), + ) + .with_question(execution.response.question.clone()) + .with_operand(AgentOperandRole::Query, execution.response.question.clone()) + .with_cursor(cursor.map(str::to_owned)) + .with_evidence_hidden(!include_evidence); + match build_discovery_query_view(&execution.response, context) + .and_then(|view| serde_json::to_string_pretty(&view).map_err(Into::into)) + { + Ok(output) => Outcome::success(output), + Err(error) => Outcome::failure(format!("error: {error}")), + } } else { let request_digest = match discovery_request_digest(&execution.response, include_heuristic) { Ok(digest) => digest, Err(error) => return Outcome::failure(format!("error: {error}")), }; - match render_discovery_text_page( + let context = AgentQueryContext::new( + AgentOperation::Discovery, + execution.graph_digest.clone(), + execution.graph_identity.clone(), + ) + .with_question(execution.response.question.clone()) + .with_operand(AgentOperandRole::Query, execution.response.question.clone()) + .with_cursor(cursor.map(str::to_owned)) + .with_evidence_hidden(!include_evidence); + let view = match build_discovery_query_view(&execution.response, context) { + Ok(view) => view, + Err(error) => return Outcome::failure(format!("error: {error}")), + }; + let mut prefix = match render_agent_query_header_lines(&view) { + Ok(prefix) => prefix, + Err(error) => return Outcome::failure(format!("error: {error}")), + }; + if include_evidence { + prefix.push(format!( + "Semantic result: {}", + view.identity.source_result_digest + )); + } + match render_discovery_text_page_with_prefix( &execution.response, DiscoveryTextPageOptions { token_budget: text_budget, @@ -5640,6 +5684,7 @@ fn command_discovery_query( graph_digest: &execution.graph_digest, include_evidence, }, + &prefix, ) { Ok(page) => Outcome::success(page.text), Err(error) => Outcome::failure(format!("error: {error}")), diff --git a/crates/compass-cli/tests/code_query_cli.rs b/crates/compass-cli/tests/code_query_cli.rs index 248f5f6e9..12a139652 100644 --- a/crates/compass-cli/tests/code_query_cli.rs +++ b/crates/compass-cli/tests/code_query_cli.rs @@ -7,6 +7,7 @@ use compass_cli::{Frontend, run}; use compass_files::BuildGuard; use compass_graph::GraphSnapshotBuilder; use compass_model::code_graph::{EdgeKind, GraphDocument}; +use compass_output::AgentQueryView; use compass_store::{STORE_FILE_NAME, STORE_REF_FILE_NAME, SqliteStore}; use serde_json::Value; @@ -46,6 +47,22 @@ fn typed_query_commands_share_the_versioned_json_contract() -> Result<(), Box Result<(), Box Result<(), Box> { let directory = tempfile::tempdir()?; let graph = support::write_typed_graph(directory.path())?; + let graph_for_agent = graph.clone(); let mut document = GraphDocument::load(&graph)?; document.links[0].context = Some("call".to_owned()); std::fs::write(&graph, serde_json::to_vec_pretty(&document)?)?; @@ -338,6 +371,21 @@ fn natural_discovery_exposes_the_public_json_contract_and_repeatable_or_scopes() assert_eq!(response["seeds"][0]["nodeId"], "n:target"); assert_eq!(response["nodes"].as_array().map(Vec::len), Some(2)); assert_eq!(response["edges"].as_array().map(Vec::len), Some(1)); + + let agent = run( + Frontend::Compass, + [ + OsString::from("query"), + OsString::from("Target"), + OsString::from("--graph"), + graph_for_agent.into_os_string(), + OsString::from("--format=agent-json"), + ], + ); + assert_eq!(agent.code, 0, "{}", agent.stderr); + let agent_view = AgentQueryView::from_json(agent.stdout.as_bytes())?; + assert_eq!(agent_view.schema, "compass.query.agent-view/1"); + assert!(!agent_view.primary_results.is_empty()); Ok(()) } @@ -492,7 +540,7 @@ fn natural_discovery_help_documents_only_the_public_contract() { "--scope ", "Repeatable OR scope", "--context ", - "--format ", + "--format ", "--result-envelope", "--text-budget ", "default: 8000", @@ -599,7 +647,8 @@ fn typed_query_text_is_a_projection_of_the_same_response() -> Result<(), Box Result<(), Bo let concise = run(Frontend::Compass, base.clone()); assert_eq!(concise.code, 0, "{}", concise.stderr); - assert!(concise.stdout.starts_with("match_confidence: exact")); + assert!(concise.stdout.starts_with("RESULT\n")); + assert!( + concise.stdout.contains("State: candidates") || concise.stdout.contains("State: answered") + ); assert!(concise.stdout.contains("NODE Fixture.Target [function]")); assert!(concise.stdout.contains("provenance record(s) hidden")); assert!(!concise.stdout.contains("Node evidence:")); @@ -777,7 +829,7 @@ fn natural_and_typed_queries_signal_missing_exact_matches_before_fallbacks() ); assert_eq!(outcome.code, 0, "{command}: {}", outcome.stderr); assert!( - outcome.stdout.starts_with("match_confidence: none"), + outcome.stdout.starts_with("RESULT\nState: no_match"), "{command}: {}", outcome.stdout ); diff --git a/crates/compass-cli/tests/install_cli.rs b/crates/compass-cli/tests/install_cli.rs index fa15ca8b0..b26b7221d 100644 --- a/crates/compass-cli/tests/install_cli.rs +++ b/crates/compass-cli/tests/install_cli.rs @@ -134,6 +134,8 @@ fn project_codex_install_creates_native_compass_skill() -> Result<(), Box, + response: &compass_model::query_contract::CodeQueryResponse, + engine: &compass_query::CodeQueryEngine, +) -> AgentQueryContext { + let mut context = AgentQueryContext::new( + AgentOperation::from(response.operation), + engine.graph_identity().to_owned(), + engine.build_generation_identity().to_owned(), + ); + let operand = |role: AgentOperandRole, name: &str, context: AgentQueryContext| match arguments + .get(name) + .and_then(Value::as_str) + { + Some(value) => context.with_operand(role, value.to_owned()), + None => context, + }; + context = match name { + "search_symbols" => operand(AgentOperandRole::Query, "query", context), + "get_callers" | "get_callees" | "get_impact" => { + operand(AgentOperandRole::Symbol, "symbol", context) + } + "get_node" => { + let context = operand(AgentOperandRole::Source, "source", context); + operand(AgentOperandRole::Target, "target", context) + } + "explore_code" => { + let mut context = context; + if let Some(values) = arguments.get("symbols").and_then(Value::as_array) { + for value in values.iter().filter_map(Value::as_str) { + context = context.with_operand(AgentOperandRole::Symbol, value.to_owned()); + } + } + operand(AgentOperandRole::Root, "root", context) + } + _ => context, + }; + context +} + fn natural_discovery_requested(arguments: &Map) -> bool { !["mode", "depth", "token_budget", "context_filter"] .iter() @@ -1094,28 +1136,35 @@ fn invoke_discovery_tool( .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let response = code_query::invoke_discovery_with_engine(arguments, &engine)?; - let text = format!( - "Discovery: {} seeds, {} nodes, {} edges{}", - response.seeds.len(), - response.nodes.len(), - response.edges.len(), - if response.truncated { - " (truncated)" - } else { - "" - } - ); + let question = arguments + .get("question") + .and_then(Value::as_str) + .unwrap_or(&response.question); + let context = AgentQueryContext::new( + AgentOperation::Discovery, + engine.graph_identity().to_owned(), + engine.build_generation_identity().to_owned(), + ) + .with_question(question.to_owned()) + .with_operand(AgentOperandRole::Query, question.to_owned()); + let view = build_discovery_query_view(&response, context) + .map_err(|error| InvocationError::Internal(error.to_string()))?; + let text = render_agent_query_text(&view) + .map_err(|error| InvocationError::Internal(error.to_string()))?; if let Some(question) = arguments.get("question").and_then(Value::as_str) { log_discovery_mcp_query(question, graph_path, &response, started.elapsed()); } let semantic_result_digest = compass_query::discovery_response_digest(&response) .map_err(|error| InvocationError::Internal(error.to_string()))?; + let view = + serde_json::to_value(view).map_err(|error| InvocationError::Internal(error.to_string()))?; Ok(ToolInvocation { text, - structured_content: Some(transport_envelope_with_digest( + structured_content: Some(transport_envelope_with_view( serde_json::to_value(response) .map_err(|error| InvocationError::Internal(error.to_string()))?, Some(&semantic_result_digest), + Some(view), )?), }) } @@ -1189,6 +1238,14 @@ fn transport_envelope(result: Value) -> Result { fn transport_envelope_with_digest( result: Value, semantic_result_digest: Option<&str>, +) -> Result { + transport_envelope_with_view(result, semantic_result_digest, None) +} + +fn transport_envelope_with_view( + result: Value, + semantic_result_digest: Option<&str>, + agent_view: Option, ) -> Result { let mut envelope = json!({ "schema": MCP_TOOL_RESULT_SCHEMA, @@ -1204,6 +1261,9 @@ fn transport_envelope_with_digest( if let Some(digest) = semantic_result_digest { envelope["semanticResultDigest"] = json!(format!("sha256:{digest}")); } + if let Some(view) = agent_view { + envelope["agentView"] = view; + } for _ in 0..8 { let required_bytes = serde_json::to_vec(&envelope) .map_err(|error| InvocationError::Internal(error.to_string()))? diff --git a/crates/compass-mcp/tests/code_query_tools.rs b/crates/compass-mcp/tests/code_query_tools.rs index 424dac065..0eea869a8 100644 --- a/crates/compass-mcp/tests/code_query_tools.rs +++ b/crates/compass-mcp/tests/code_query_tools.rs @@ -210,6 +210,25 @@ fn code_query_tools_share_the_bounded_versioned_contract() -> Result<(), Box &'static str { + match self { + Self::Discovery => "discovery", + Self::Search => "search", + Self::Callers => "callers", + Self::Callees => "callees", + Self::Impact => "impact", + Self::Explore => "explore", + Self::NodeTrail => "node_trail", + } + } +} + +impl From for AgentOperation { + fn from(operation: CodeQueryOperation) -> Self { + match operation { + CodeQueryOperation::Search => Self::Search, + CodeQueryOperation::Callers => Self::Callers, + CodeQueryOperation::Callees => Self::Callees, + CodeQueryOperation::Impact => Self::Impact, + CodeQueryOperation::Explore => Self::Explore, + CodeQueryOperation::NodeTrail => Self::NodeTrail, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AgentQueryContext { + pub operation: AgentOperation, + pub question: Option, + pub operands: Vec, + pub graph_identity: String, + pub build_generation_identity: String, + pub continuation_cursor: Option, + pub evidence_hidden: bool, +} + +impl AgentQueryContext { + #[must_use] + pub fn new( + operation: AgentOperation, + graph_identity: impl Into, + build_generation_identity: impl Into, + ) -> Self { + Self { + operation, + question: None, + operands: Vec::new(), + graph_identity: graph_identity.into(), + build_generation_identity: build_generation_identity.into(), + continuation_cursor: None, + evidence_hidden: false, + } + } + + #[must_use] + pub fn with_question(mut self, question: impl Into) -> Self { + self.question = Some(question.into()); + self + } + + #[must_use] + pub fn with_operand(mut self, role: AgentOperandRole, value: impl Into) -> Self { + self.operands.push(AgentOperand { + role, + value: value.into(), + }); + self + } + + #[must_use] + pub fn with_cursor(mut self, cursor: Option) -> Self { + self.continuation_cursor = cursor; + self + } + + #[must_use] + pub const fn with_evidence_hidden(mut self, hidden: bool) -> Self { + self.evidence_hidden = hidden; + self + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AgentOperandRole { + Query, + Symbol, + Source, + Target, + Root, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AgentOperand { + pub role: AgentOperandRole, + pub value: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AgentQueryView { + pub schema: String, + pub request: AgentRequest, + pub status: AgentStatus, + pub answer: AgentAnswer, + pub primary_results: Vec, + pub relationships: Vec, + pub paths: Vec, + pub caveats: Vec, + pub next_actions: Vec, + pub omissions: AgentOmissions, + pub identity: AgentIdentity, + pub source_truncated: bool, + pub projection_truncated: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AgentRequest { + pub operation: AgentOperation, + #[serde(skip_serializing_if = "Option::is_none")] + pub question: Option, + pub operands: Vec, +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AgentResultState { + Answered, + Candidates, + NeedsResolution, + NoMatch, + NoPath, +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AgentMatch { + Exact, + Fuzzy, + Ambiguous, + None, + NotApplicable, + Unknown, +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AgentEvidence { + Exact, + Inferred, + Mixed, + Ambiguous, + None, +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AgentExecution { + Complete, + Partial, +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AgentProjection { + Complete, + Partial, +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AgentCoverage { + Incomplete, + Unknown, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AgentStatus { + pub result_state: AgentResultState, + pub match_state: AgentMatch, + pub evidence_state: AgentEvidence, + pub source_execution: AgentExecution, + pub projection: AgentProjection, + pub coverage: AgentCoverage, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AgentAnswer { + pub headline: String, + pub basis: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AgentBasis { + pub kind: String, + pub id: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AgentSource { + pub file: String, + pub start_line: u32, + pub start_column: u32, + pub end_line: u32, + pub end_column: u32, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AgentEntity { + pub id: String, + pub label: String, + pub kind: String, + pub roles: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub language: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub framework: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub source: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AgentEndpoint { + pub id: String, + pub label: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AgentRelationship { + pub id: String, + pub source: AgentEndpoint, + pub relation: String, + pub target: AgentEndpoint, + #[serde(skip_serializing_if = "Option::is_none")] + pub site: Option, + pub evidence: AgentRelationshipEvidence, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AgentRelationshipEvidence { + pub confidence: String, + pub resolution: String, + pub layers: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AgentPathDirection { + Forward, + Reverse, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AgentPathStep { + pub from: AgentEndpoint, + pub edge_id: String, + pub relation: String, + pub direction: AgentPathDirection, + pub to: AgentEndpoint, + #[serde(skip_serializing_if = "Option::is_none")] + pub site: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AgentPath { + pub id: String, + pub steps: Vec, + pub weakest_resolution: String, + pub weakest_confidence: String, +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AgentSeverity { + Blocker, + Warning, + Info, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AgentCaveat { + pub severity: AgentSeverity, + pub code: String, + pub statement: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub node_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AgentActionCli { + pub argv: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AgentActionMcp { + pub tool: String, + pub arguments: Map, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AgentNextAction { + pub kind: String, + pub reason: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub cli: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub mcp: Option, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AgentOmissions { + pub primary_results: usize, + pub relationships: usize, + pub paths: usize, + pub caveats: usize, + pub next_actions: usize, + pub total: usize, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AgentIdentity { + pub raw_schema: String, + pub graph_identity: String, + pub build_generation_identity: String, + pub source_result_digest: String, + pub view_digest: String, +} + +fn invalid(reason: impl Into) -> OutputError { + OutputError::InvalidAgentQuery(reason.into()) +} + +pub fn build_code_query_view( + response: &CodeQueryResponse, + context: AgentQueryContext, +) -> Result { + if response.schema != compass_model::query_contract::CODE_QUERY_SCHEMA_V1 { + return Err(invalid(format!( + "unsupported raw query schema {}", + response.schema + ))); + } + if context.operation == AgentOperation::Discovery { + return Err(invalid("code query context cannot use discovery operation")); + } + let source_digest = format!( + "sha256:{}", + code_query_response_digest(response).map_err(|error| invalid(error.to_string()))? + ); + let nodes = response + .nodes + .iter() + .map(|node| (node.id.clone(), node)) + .collect::>(); + let primary_ids = primary_node_ids(context.operation, &context.operands, response, &nodes); + let mut primary_results = primary_ids + .iter() + .filter_map(|id| nodes.get(id).copied()) + .map(agent_entity) + .collect::>(); + let before_primary = primary_results.len(); + primary_results.truncate(AGENT_VIEW_MAX_PRIMARY_RESULTS); + let primary_omitted = before_primary.saturating_sub(primary_results.len()); + + let mut all_relationships = response + .edges + .iter() + .enumerate() + .map(|(index, edge)| agent_relationship(edge, index, &nodes)) + .collect::>(); + all_relationships.sort_by(|left, right| { + left.id + .cmp(&right.id) + .then_with(|| left.source.id.cmp(&right.source.id)) + .then_with(|| left.target.id.cmp(&right.target.id)) + }); + let before_relationships = all_relationships.len(); + let relationships = all_relationships + .into_iter() + .take(AGENT_VIEW_MAX_RELATIONSHIPS) + .collect::>(); + let relationship_omitted = before_relationships.saturating_sub(relationships.len()); + + let mut all_paths = response + .paths + .iter() + .map(|path| agent_path(path, &nodes, &response.edges)) + .collect::>(); + all_paths.sort_by(|left, right| left.id.cmp(&right.id)); + let before_paths = all_paths.len(); + let paths = all_paths + .into_iter() + .take(AGENT_VIEW_MAX_PATHS) + .collect::>(); + let path_omitted = before_paths.saturating_sub(paths.len()); + + let mut all_caveats = response + .diagnostics + .iter() + .map(agent_caveat) + .collect::>(); + all_caveats.sort_by(|left, right| { + left.severity + .cmp(&right.severity) + .then_with(|| left.code.cmp(&right.code)) + .then_with(|| left.node_id.cmp(&right.node_id)) + .then_with(|| left.path.cmp(&right.path)) + .then_with(|| left.statement.cmp(&right.statement)) + }); + let before_caveats = all_caveats.len(); + let caveats = all_caveats + .into_iter() + .take(AGENT_VIEW_MAX_CAVEATS) + .collect::>(); + let caveat_omitted = before_caveats.saturating_sub(caveats.len()); + + let source_truncated = response.truncated + || response + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == QueryDiagnosticCode::BoundedTruncation); + let match_state = code_match_state(&context, response, &nodes); + let result_state = code_result_state(context.operation, match_state, response); + let evidence_state = evidence_state( + response + .nodes + .iter() + .flat_map(|node| node.evidence.iter()) + .chain(response.edges.iter().flat_map(|edge| edge.evidence.iter())), + ); + let coverage = if has_diagnostic( + response.diagnostics.as_slice(), + QueryDiagnosticCode::IncompleteCoverage, + ) { + AgentCoverage::Incomplete + } else { + AgentCoverage::Unknown + }; + let answer = answer_for_code( + &context, + result_state, + match_state, + response, + &primary_results, + &relationships, + &paths, + ); + let next_actions = next_actions_for_code( + &context, + &primary_results, + &caveats, + source_truncated, + &response.paths, + ); + let mut view = AgentQueryView { + schema: AGENT_QUERY_VIEW_SCHEMA.to_owned(), + request: AgentRequest { + operation: context.operation, + question: context.question, + operands: context.operands, + }, + status: AgentStatus { + result_state, + match_state, + evidence_state, + source_execution: if source_truncated { + AgentExecution::Partial + } else { + AgentExecution::Complete + }, + projection: AgentProjection::Complete, + coverage, + }, + answer, + primary_results, + relationships, + paths, + caveats, + next_actions, + omissions: AgentOmissions { + primary_results: primary_omitted, + relationships: relationship_omitted, + paths: path_omitted, + caveats: caveat_omitted, + next_actions: 0, + total: primary_omitted + relationship_omitted + path_omitted + caveat_omitted, + }, + identity: AgentIdentity { + raw_schema: response.schema.clone(), + graph_identity: context.graph_identity, + build_generation_identity: context.build_generation_identity, + source_result_digest: source_digest, + view_digest: String::new(), + }, + source_truncated, + projection_truncated: false, + }; + let before_actions = view.next_actions.len(); + view.next_actions.truncate(AGENT_VIEW_MAX_NEXT_ACTIONS); + view.omissions.next_actions = before_actions.saturating_sub(view.next_actions.len()); + view.omissions.total = view + .omissions + .primary_results + .saturating_add(view.omissions.relationships) + .saturating_add(view.omissions.paths) + .saturating_add(view.omissions.caveats) + .saturating_add(view.omissions.next_actions); + view.projection_truncated = view.omissions.total > 0; + view.status.projection = if view.projection_truncated { + AgentProjection::Partial + } else { + AgentProjection::Complete + }; + finish_view(view) +} + +pub fn build_discovery_query_view( + response: &DiscoveryQueryResponse, + context: AgentQueryContext, +) -> Result { + if response.schema != compass_model::query_contract::DISCOVERY_QUERY_SCHEMA_V1 { + return Err(invalid(format!( + "unsupported raw discovery schema {}", + response.schema + ))); + } + if context.operation != AgentOperation::Discovery { + return Err(invalid( + "discovery query context must use discovery operation", + )); + } + let source_digest = format!( + "sha256:{}", + discovery_response_digest(response).map_err(|error| invalid(error.to_string()))? + ); + let nodes = response + .nodes + .iter() + .map(|node| (node.id.clone(), node)) + .collect::>(); + let seed_ids = response + .seeds + .iter() + .map(|seed| seed.node_id.clone()) + .collect::>(); + let seed_set = seed_ids.iter().cloned().collect::>(); + let primary_ids = seed_ids + .into_iter() + .chain(nodes.keys().filter(|id| !seed_set.contains(*id)).cloned()) + .collect::>(); + let mut primary_results = primary_ids + .iter() + .filter_map(|id| nodes.get(id).copied()) + .map(agent_entity) + .collect::>(); + deduplicate_entities(&mut primary_results); + let before_primary = primary_results.len(); + primary_results.truncate(AGENT_VIEW_MAX_PRIMARY_RESULTS); + let primary_omitted = before_primary.saturating_sub(primary_results.len()); + + let mut all_relationships = response + .edges + .iter() + .enumerate() + .map(|(index, edge)| agent_discovery_relationship(edge, index, &nodes)) + .collect::>(); + all_relationships.sort_by(|left, right| { + left.id + .cmp(&right.id) + .then_with(|| left.source.id.cmp(&right.source.id)) + .then_with(|| left.target.id.cmp(&right.target.id)) + }); + let before_relationships = all_relationships.len(); + let relationships = all_relationships + .into_iter() + .take(AGENT_VIEW_MAX_RELATIONSHIPS) + .collect::>(); + let relationship_omitted = before_relationships.saturating_sub(relationships.len()); + let mut all_caveats = response + .diagnostics + .iter() + .map(agent_caveat) + .collect::>(); + all_caveats.sort_by(|left, right| { + left.severity + .cmp(&right.severity) + .then_with(|| left.code.cmp(&right.code)) + .then_with(|| left.node_id.cmp(&right.node_id)) + .then_with(|| left.path.cmp(&right.path)) + .then_with(|| left.statement.cmp(&right.statement)) + }); + let before_caveats = all_caveats.len(); + let caveats = all_caveats + .into_iter() + .take(AGENT_VIEW_MAX_CAVEATS) + .collect::>(); + let caveat_omitted = before_caveats.saturating_sub(caveats.len()); + let ambiguous = response.seeds.iter().any(|seed| seed.ambiguous) + || has_diagnostic( + response.diagnostics.as_slice(), + QueryDiagnosticCode::AmbiguousMatch, + ); + let no_match = has_diagnostic( + response.diagnostics.as_slice(), + QueryDiagnosticCode::NoMatch, + ); + let match_state = if ambiguous { + AgentMatch::Ambiguous + } else if no_match { + AgentMatch::None + } else { + AgentMatch::Fuzzy + }; + let result_state = if ambiguous { + AgentResultState::NeedsResolution + } else if no_match { + AgentResultState::NoMatch + } else { + AgentResultState::Candidates + }; + let source_truncated = response.truncated + || has_diagnostic( + response.diagnostics.as_slice(), + QueryDiagnosticCode::BoundedTruncation, + ); + let evidence_state = evidence_state( + response + .nodes + .iter() + .flat_map(|node| node.evidence.iter()) + .chain(response.edges.iter().flat_map(|edge| edge.evidence.iter())), + ); + let coverage = if has_diagnostic( + response.diagnostics.as_slice(), + QueryDiagnosticCode::IncompleteCoverage, + ) { + AgentCoverage::Incomplete + } else { + AgentCoverage::Unknown + }; + let answer = answer_for_discovery( + result_state, + &response.question, + response.seeds.len(), + relationships.len(), + &primary_results, + ); + let next_actions = next_actions_for_discovery(&context, &primary_results, source_truncated); + let mut view = AgentQueryView { + schema: AGENT_QUERY_VIEW_SCHEMA.to_owned(), + request: AgentRequest { + operation: AgentOperation::Discovery, + question: context.question.or_else(|| Some(response.question.clone())), + operands: context.operands, + }, + status: AgentStatus { + result_state, + match_state, + evidence_state, + source_execution: if source_truncated { + AgentExecution::Partial + } else { + AgentExecution::Complete + }, + projection: AgentProjection::Complete, + coverage, + }, + answer, + primary_results, + relationships, + paths: Vec::new(), + caveats, + next_actions, + omissions: AgentOmissions { + primary_results: primary_omitted, + relationships: relationship_omitted, + paths: 0, + caveats: caveat_omitted, + next_actions: 0, + total: primary_omitted + relationship_omitted + caveat_omitted, + }, + identity: AgentIdentity { + raw_schema: response.schema.clone(), + graph_identity: context.graph_identity, + build_generation_identity: context.build_generation_identity, + source_result_digest: source_digest, + view_digest: String::new(), + }, + source_truncated, + projection_truncated: false, + }; + let before_actions = view.next_actions.len(); + view.next_actions.truncate(AGENT_VIEW_MAX_NEXT_ACTIONS); + view.omissions.next_actions = before_actions.saturating_sub(view.next_actions.len()); + view.omissions.total = view + .omissions + .primary_results + .saturating_add(view.omissions.relationships) + .saturating_add(view.omissions.paths) + .saturating_add(view.omissions.caveats) + .saturating_add(view.omissions.next_actions); + view.projection_truncated = view.omissions.total > 0; + view.status.projection = if view.projection_truncated { + AgentProjection::Partial + } else { + AgentProjection::Complete + }; + finish_view(view) +} + +fn finish_view(mut view: AgentQueryView) -> Result { + if view.omissions.total > 0 { + view.projection_truncated = true; + } + view.identity.view_digest = view_digest(&view)?; + view.validate()?; + let bytes = serde_json::to_vec(&view).map_err(|error| invalid(error.to_string()))?; + if bytes.len() > AGENT_VIEW_MAX_BYTES { + return Err(invalid(format!( + "serialized view is {} bytes; limit is {}", + bytes.len(), + AGENT_VIEW_MAX_BYTES + ))); + } + Ok(view) +} + +impl AgentQueryView { + pub fn from_json(bytes: &[u8]) -> Result { + let view: Self = + serde_json::from_slice(bytes).map_err(|error| invalid(error.to_string()))?; + view.validate()?; + Ok(view) + } + + pub fn validate(&self) -> Result<(), OutputError> { + if self.schema != AGENT_QUERY_VIEW_SCHEMA { + return Err(invalid(format!( + "unsupported Agent View schema {}", + self.schema + ))); + } + for (name, value) in [ + ("graph identity", self.identity.graph_identity.as_str()), + ( + "build generation identity", + self.identity.build_generation_identity.as_str(), + ), + ( + "source result digest", + self.identity.source_result_digest.as_str(), + ), + ("view digest", self.identity.view_digest.as_str()), + ] { + if value.is_empty() { + return Err(invalid(format!("{name} must not be empty"))); + } + } + if !valid_sha256(&self.identity.source_result_digest) + || !valid_sha256(&self.identity.view_digest) + { + return Err(invalid( + "Agent View digests must be sha256:<64 lowercase hex>", + )); + } + if self.primary_results.len() > AGENT_VIEW_MAX_PRIMARY_RESULTS + || self.relationships.len() > AGENT_VIEW_MAX_RELATIONSHIPS + || self.paths.len() > AGENT_VIEW_MAX_PATHS + || self.caveats.len() > AGENT_VIEW_MAX_CAVEATS + || self.next_actions.len() > AGENT_VIEW_MAX_NEXT_ACTIONS + { + return Err(invalid("Agent View item bound exceeded")); + } + if self.status.source_execution == AgentExecution::Complete && self.source_truncated { + return Err(invalid("complete execution cannot be source-truncated")); + } + if self.status.projection == AgentProjection::Complete + && (self.projection_truncated || self.omissions.total > 0) + { + return Err(invalid("complete projection cannot contain omissions")); + } + if self.status.result_state == AgentResultState::NoMatch + && !self.caveats.iter().any(|caveat| caveat.code == "no_match") + { + return Err(invalid("no_match requires a no_match caveat")); + } + if self.status.result_state == AgentResultState::NeedsResolution + && self.primary_results.len() < 2 + && self.omissions.primary_results == 0 + && !self + .caveats + .iter() + .any(|caveat| caveat.code == "ambiguous_match") + { + return Err(invalid( + "needs_resolution requires two retained candidates or an omission", + )); + } + if self.answer.headline.is_empty() || self.answer.basis.is_empty() { + return Err(invalid("answer headline and basis are required")); + } + let primary_ids = self + .primary_results + .iter() + .map(|entity| entity.id.as_str()) + .collect::>(); + let relationship_ids = self + .relationships + .iter() + .map(|relationship| relationship.id.as_str()) + .collect::>(); + let path_ids = self + .paths + .iter() + .map(|path| path.id.as_str()) + .collect::>(); + for basis in &self.answer.basis { + if basis.id.is_empty() { + return Err(invalid("answer basis IDs are required")); + } + let valid = match basis.kind.as_str() { + "node" => primary_ids.contains(basis.id.as_str()), + "relationship" => relationship_ids.contains(basis.id.as_str()), + "path" => path_ids.contains(basis.id.as_str()), + "operation" => basis.id == self.request.operation.label(), + _ => false, + }; + if !valid { + return Err(invalid(format!( + "answer basis {} does not reference a retained result", + basis.id + ))); + } + } + if self.status.result_state == AgentResultState::Answered + && matches!( + self.status.match_state, + AgentMatch::Ambiguous | AgentMatch::None + ) + { + return Err(invalid( + "answered cannot have ambiguous or none match state", + )); + } + for entity in &self.primary_results { + if entity.id.is_empty() || entity.label.is_empty() { + return Err(invalid("Agent entity IDs and labels are required")); + } + } + for relationship in &self.relationships { + validate_endpoint(&relationship.source)?; + validate_endpoint(&relationship.target)?; + } + for path in &self.paths { + if path.id.is_empty() { + return Err(invalid("path IDs are required")); + } + for (index, step) in path.steps.iter().enumerate() { + validate_endpoint(&step.from)?; + validate_endpoint(&step.to)?; + if step.edge_id.is_empty() || step.relation.is_empty() { + return Err(invalid("path edge IDs and relations are required")); + } + if index > 0 && path.steps[index - 1].to.id != step.from.id { + return Err(invalid("path steps must form a connected trail")); + } + } + } + let bytes = serde_json::to_vec(self).map_err(|error| invalid(error.to_string()))?; + if bytes.len() > AGENT_VIEW_MAX_BYTES { + return Err(invalid(format!( + "serialized view is {} bytes; limit is {}", + bytes.len(), + AGENT_VIEW_MAX_BYTES + ))); + } + if view_digest(self)? != self.identity.view_digest { + return Err(invalid("Agent View digest does not match its contents")); + } + Ok(()) + } +} + +fn validate_endpoint(endpoint: &AgentEndpoint) -> Result<(), OutputError> { + if endpoint.id.is_empty() || endpoint.label.is_empty() { + return Err(invalid("relationship endpoint IDs and labels are required")); + } + Ok(()) +} + +pub fn render_agent_query_header_lines(view: &AgentQueryView) -> Result, OutputError> { + view.validate()?; + let mut lines = render_result_lines(view); + lines.push(String::new()); + lines.push("ANSWER".to_owned()); + lines.push(escape_scalar(&view.answer.headline)); + if !view.caveats.is_empty() { + lines.push(String::new()); + lines.push("CAVEATS".to_owned()); + lines.extend(view.caveats.iter().map(render_caveat)); + } + Ok(lines) +} + +pub fn render_agent_query_text(view: &AgentQueryView) -> Result { + let mut lines = render_agent_query_header_lines(view)?; + lines.push(String::new()); + lines.push("PRIMARY RESULTS".to_owned()); + if view.primary_results.is_empty() { + lines.push("- None retained.".to_owned()); + } else { + lines.extend(view.primary_results.iter().map(render_entity)); + } + lines.push(String::new()); + lines.push("PATHS".to_owned()); + if view.paths.is_empty() { + lines.push("- None retained.".to_owned()); + } else { + lines.extend(view.paths.iter().map(render_path)); + } + lines.push(String::new()); + lines.push("RELATIONSHIPS".to_owned()); + if view.relationships.is_empty() { + lines.push("- None retained.".to_owned()); + } else { + lines.extend(view.relationships.iter().map(render_relationship)); + } + lines.push(String::new()); + lines.push("NEXT ACTIONS".to_owned()); + if view.next_actions.is_empty() { + lines.push("- None.".to_owned()); + } else { + lines.extend(view.next_actions.iter().map(render_action)); + } + lines.push(String::new()); + lines.push("DETAILS".to_owned()); + lines.push(format!( + "{} primary result(s) · {} relationship(s) · {} path(s)", + view.primary_results.len(), + view.relationships.len(), + view.paths.len() + )); + if view.omissions.total > 0 { + lines.push(format!( + "{} record(s) omitted by the Agent View bound; raw evidence is unchanged.", + view.omissions.total + )); + } + lines.push("Full provenance is available in the raw JSON/evidence view.".to_owned()); + let text = lines.join("\n"); + if text.len() > AGENT_VIEW_TEXT_MAX_BYTES { + return Err(OutputError::AgentQueryTextBudgetExceeded { + rendered_bytes: text.len(), + limit: AGENT_VIEW_TEXT_MAX_BYTES, + }); + } + Ok(text) +} + +fn render_result_lines(view: &AgentQueryView) -> Vec { + vec![ + "RESULT".to_owned(), + format!("State: {}", result_state_name(view.status.result_state)), + format!("Match: {}", match_state_name(view.status.match_state)), + format!( + "Evidence: {}", + evidence_state_name(view.status.evidence_state) + ), + format!( + "Execution: {} within requested bounds", + execution_state_name(view.status.source_execution) + ), + format!("Coverage: {}", coverage_state_name(view.status.coverage)), + ] +} + +fn result_state_name(value: AgentResultState) -> &'static str { + match value { + AgentResultState::Answered => "answered", + AgentResultState::Candidates => "candidates", + AgentResultState::NeedsResolution => "needs_resolution", + AgentResultState::NoMatch => "no_match", + AgentResultState::NoPath => "no_path", + } +} + +fn match_state_name(value: AgentMatch) -> &'static str { + match value { + AgentMatch::Exact => "exact", + AgentMatch::Fuzzy => "fuzzy", + AgentMatch::Ambiguous => "ambiguous", + AgentMatch::None => "none", + AgentMatch::NotApplicable => "not_applicable", + AgentMatch::Unknown => "unknown", + } +} + +fn evidence_state_name(value: AgentEvidence) -> &'static str { + match value { + AgentEvidence::Exact => "exact", + AgentEvidence::Inferred => "inferred", + AgentEvidence::Mixed => "mixed", + AgentEvidence::Ambiguous => "ambiguous", + AgentEvidence::None => "none", + } +} + +fn execution_state_name(value: AgentExecution) -> &'static str { + match value { + AgentExecution::Complete => "complete", + AgentExecution::Partial => "partial", + } +} + +fn coverage_state_name(value: AgentCoverage) -> &'static str { + match value { + AgentCoverage::Incomplete => "incomplete", + AgentCoverage::Unknown => "unknown", + } +} + +fn render_entity(entity: &AgentEntity) -> String { + let source = entity + .source + .as_ref() + .map(render_source) + .unwrap_or_else(|| "source unavailable".to_owned()); + format!( + "- {} [{}] {}\n id: {}", + escape_scalar(&entity.label), + escape_scalar(&entity.kind), + escape_scalar(&source), + escape_scalar(&entity.id) + ) +} + +fn render_relationship(relationship: &AgentRelationship) -> String { + let site = relationship + .site + .as_ref() + .map(render_source) + .unwrap_or_else(|| "site unavailable".to_owned()); + format!( + "- {} --{}--> {}\n {} · {} · {}", + escape_scalar(&relationship.source.label), + escape_scalar(&relationship.relation), + escape_scalar(&relationship.target.label), + escape_scalar(&site), + escape_scalar(&relationship.evidence.confidence), + escape_scalar(&relationship.evidence.resolution) + ) +} + +fn render_path(path: &AgentPath) -> String { + let mut segments = Vec::new(); + if let Some(first) = path.steps.first() { + segments.push(escape_scalar(&first.from.label)); + } + for step in &path.steps { + let arrow = match step.direction { + AgentPathDirection::Forward => format!("--{}-->", escape_scalar(&step.relation)), + AgentPathDirection::Reverse => format!("<--{}--", escape_scalar(&step.relation)), + }; + segments.push(arrow); + segments.push(escape_scalar(&step.to.label)); + } + format!( + "- {} ({} hop(s)): {}", + escape_scalar(&path.id), + path.steps.len(), + segments.join(" ") + ) +} + +fn render_caveat(caveat: &AgentCaveat) -> String { + format!( + "- [{}] {}: {}", + severity_name(caveat.severity), + escape_scalar(&caveat.code), + escape_scalar(&caveat.statement) + ) +} + +fn severity_name(value: AgentSeverity) -> &'static str { + match value { + AgentSeverity::Blocker => "blocker", + AgentSeverity::Warning => "warning", + AgentSeverity::Info => "info", + } +} + +fn render_action(action: &AgentNextAction) -> String { + if let Some(mcp) = &action.mcp { + let arguments = serde_json::to_string(&mcp.arguments).unwrap_or_else(|_| "{}".to_owned()); + return format!( + "- {}: {} · MCP {} {}", + escape_scalar(&action.kind), + escape_scalar(&action.reason), + escape_scalar(&mcp.tool), + escape_scalar(&arguments) + ); + } + let cli = action + .cli + .as_ref() + .map(|action| { + action + .argv + .iter() + .map(|value| escape_scalar(value)) + .collect::>() + .join(" ") + }) + .unwrap_or_else(|| "unavailable".to_owned()); + format!( + "- {}: {} · CLI {}", + escape_scalar(&action.kind), + escape_scalar(&action.reason), + cli + ) +} + +fn render_source(source: &AgentSource) -> String { + format!( + "{}:L{}:{}-L{}:{}", + escape_scalar(&source.file), + source.start_line, + source.start_column, + source.end_line, + source.end_column + ) +} + +fn escape_scalar(value: &str) -> String { + let mut output = String::new(); + for character in value.chars().take(AGENT_VIEW_MAX_SCALAR_CHARS) { + let code = u32::from(character); + let bidi = matches!( + code, + 0x061c | 0x200e..=0x200f | 0x202a..=0x202e | 0x2066..=0x2069 + ); + if character.is_control() || bidi { + let _ = write!(output, "\\u{{{code:x}}}"); + } else { + output.push(character); + } + } + if value.chars().count() > AGENT_VIEW_MAX_SCALAR_CHARS { + output.push('…'); + } + output +} + +fn primary_node_ids( + operation: AgentOperation, + operands: &[AgentOperand], + response: &CodeQueryResponse, + nodes: &BTreeMap, +) -> Vec { + let mut ordered = Vec::new(); + let requested = operands + .iter() + .filter_map(|operand| unique_node_id(&operand.value, nodes)) + .collect::>(); + match operation { + AgentOperation::Search => { + ordered.extend(response.results.iter().map(|hit| hit.node_id.clone())); + ordered.extend(requested); + } + AgentOperation::Callers => { + if let Some(target) = requested.first() { + ordered.push(target.clone()); + ordered.extend( + response + .edges + .iter() + .filter(|edge| edge.target == *target) + .map(|edge| edge.source.clone()), + ); + } else { + ordered.extend(response.results.iter().map(|hit| hit.node_id.clone())); + ordered.extend(requested); + } + } + AgentOperation::Callees => { + if let Some(source) = requested.first() { + ordered.push(source.clone()); + ordered.extend( + response + .edges + .iter() + .filter(|edge| edge.source == *source) + .map(|edge| edge.target.clone()), + ); + } else { + ordered.extend(response.results.iter().map(|hit| hit.node_id.clone())); + ordered.extend(requested); + } + } + AgentOperation::Impact => { + ordered.extend(requested); + ordered.extend( + response + .paths + .iter() + .filter_map(|path| path.node_ids.last().cloned()), + ); + } + AgentOperation::Explore => { + ordered.extend( + response + .paths + .iter() + .flat_map(|path| [path.node_ids.first(), path.node_ids.last()]) + .flatten() + .cloned(), + ); + ordered.extend(requested); + ordered.extend(response.results.iter().map(|hit| hit.node_id.clone())); + } + AgentOperation::NodeTrail => { + if let Some(path) = response.paths.first() { + ordered.extend(path.node_ids.iter().cloned()); + } else { + ordered.extend(requested); + } + } + AgentOperation::Discovery => {} + } + ordered.extend(nodes.keys().cloned()); + ordered +} + +fn unique_node_id(value: &str, nodes: &BTreeMap) -> Option { + if nodes.contains_key(value) { + return Some(value.to_owned()); + } + let mut matches = nodes + .values() + .filter(|node| node.name == value || node.qualified_name == value) + .map(|node| node.id.clone()) + .collect::>(); + matches.sort(); + matches.dedup(); + (matches.len() == 1).then(|| matches.remove(0)) +} + +fn deduplicate_entities(entities: &mut Vec) { + let mut seen = HashSet::new(); + entities.retain(|entity| seen.insert(entity.id.clone())); +} + +fn agent_entity(node: &QueryNode) -> AgentEntity { + AgentEntity { + id: node.id.clone(), + label: display_label(node), + kind: node.kind.as_str().to_owned(), + roles: node + .roles + .iter() + .map(|role| role_name(*role).to_owned()) + .collect(), + language: node.language.clone(), + framework: node.framework.clone(), + source: node.source.as_ref().map(agent_source), + } +} + +fn endpoint(id: &str, nodes: &BTreeMap) -> AgentEndpoint { + AgentEndpoint { + id: id.to_owned(), + label: nodes + .get(id) + .map_or_else(|| id.to_owned(), |node| display_label(node)), + } +} + +fn agent_relationship( + edge: &QueryEdge, + index: usize, + nodes: &BTreeMap, +) -> AgentRelationship { + AgentRelationship { + id: edge.id.clone(), + source: endpoint(&edge.source, nodes), + relation: edge.kind.as_str().to_owned(), + target: endpoint(&edge.target, nodes), + site: edge.relationship_site.as_ref().map(agent_source), + evidence: relationship_evidence(&edge.evidence, format!("edge-{index}")), + } +} + +fn agent_discovery_relationship( + edge: &DiscoveryEdge, + index: usize, + nodes: &BTreeMap, +) -> AgentRelationship { + AgentRelationship { + id: edge + .id + .clone() + .unwrap_or_else(|| format!("anonymous-edge-{index}")), + source: endpoint(&edge.source, nodes), + relation: edge.kind.as_str().to_owned(), + target: endpoint(&edge.target, nodes), + site: edge.relationship_site.as_ref().map(agent_source), + evidence: relationship_evidence(&edge.evidence, format!("edge-{index}")), + } +} + +fn agent_path( + path: &QueryPath, + nodes: &BTreeMap, + edges: &[QueryEdge], +) -> AgentPath { + let edges = edges + .iter() + .map(|edge| (edge.id.as_str(), edge)) + .collect::>(); + let mut steps = Vec::new(); + for (index, edge_id) in path.edge_ids.iter().enumerate() { + let Some(edge) = edges.get(edge_id.as_str()) else { + continue; + }; + let Some(from) = path.node_ids.get(index) else { + continue; + }; + let Some(to) = path.node_ids.get(index + 1) else { + continue; + }; + let direction = if edge.source == *from && edge.target == *to { + AgentPathDirection::Forward + } else { + AgentPathDirection::Reverse + }; + steps.push(AgentPathStep { + from: endpoint(from, nodes), + edge_id: edge.id.clone(), + relation: edge.kind.as_str().to_owned(), + direction, + to: endpoint(to, nodes), + site: edge.relationship_site.as_ref().map(agent_source), + }); + } + AgentPath { + id: path.id.clone(), + steps, + weakest_resolution: resolution_name(path.weakest_resolution).to_owned(), + weakest_confidence: confidence_name(path.weakest_confidence).to_owned(), + } +} + +fn display_label(node: &QueryNode) -> String { + if !node.qualified_name.is_empty() { + node.qualified_name.clone() + } else if !node.name.is_empty() { + node.name.clone() + } else { + node.id.clone() + } +} + +fn agent_source(anchor: &SourceAnchor) -> AgentSource { + AgentSource { + file: anchor.file.clone(), + start_line: anchor.start_line, + start_column: anchor.start_column, + end_line: anchor.end_line, + end_column: anchor.end_column, + } +} + +fn role_name(role: NodeRole) -> &'static str { + match role { + NodeRole::Controller => "controller", + NodeRole::RouteHandler => "route_handler", + NodeRole::Middleware => "middleware", + NodeRole::Service => "service", + NodeRole::Resolver => "resolver", + NodeRole::Consumer => "consumer", + NodeRole::Producer => "producer", + NodeRole::Subscriber => "subscriber", + NodeRole::Repository => "repository", + NodeRole::Model => "model", + NodeRole::Test => "test", + NodeRole::Fixture => "fixture", + NodeRole::Generated => "generated", + NodeRole::UiComponent => "ui_component", + NodeRole::Hook => "hook", + NodeRole::ClientBoundary => "client_boundary", + NodeRole::ClientComponent => "client_component", + NodeRole::ServerComponent => "server_component", + NodeRole::ServerFunction => "server_function", + NodeRole::DataLoader => "data_loader", + } +} + +fn resolution_name(value: ResolutionState) -> &'static str { + match value { + ResolutionState::Exact => "exact", + ResolutionState::Ambiguous => "ambiguous", + ResolutionState::Unresolved => "unresolved", + } +} + +fn confidence_name(value: EvidenceConfidence) -> &'static str { + match value { + EvidenceConfidence::Exact => "exact", + EvidenceConfidence::Inferred => "inferred", + EvidenceConfidence::Ambiguous => "ambiguous", + } +} + +fn relationship_evidence( + evidence: &[QueryEvidence], + fallback: String, +) -> AgentRelationshipEvidence { + let confidence = evidence + .iter() + .map(|item| item.confidence) + .min_by_key(|value| confidence_rank(*value)) + .map(confidence_name) + .unwrap_or("unknown"); + let resolution = evidence + .iter() + .map(|item| item.resolution) + .min_by_key(|value| resolution_rank(*value)) + .map(resolution_name) + .unwrap_or("unknown"); + let mut layers = evidence + .iter() + .map(|item| match item.layer { + QueryEvidenceLayer::StructuralGraph => "structural_graph".to_owned(), + QueryEvidenceLayer::ProgramIr => "program_ir".to_owned(), + }) + .collect::>(); + layers.sort(); + layers.dedup(); + if layers.is_empty() { + layers.push(fallback); + } + AgentRelationshipEvidence { + confidence: confidence.to_owned(), + resolution: resolution.to_owned(), + layers, + } +} + +fn evidence_state<'a>(evidence: impl Iterator) -> AgentEvidence { + let mut has_exact = false; + let mut has_inferred = false; + let mut has_ambiguous = false; + for item in evidence { + match item.confidence { + EvidenceConfidence::Exact => has_exact = true, + EvidenceConfidence::Inferred => has_inferred = true, + EvidenceConfidence::Ambiguous => has_ambiguous = true, + } + if item.resolution != ResolutionState::Exact { + has_inferred = true; + } + } + if has_ambiguous { + AgentEvidence::Ambiguous + } else if has_exact && has_inferred { + AgentEvidence::Mixed + } else if has_inferred { + AgentEvidence::Inferred + } else if has_exact { + AgentEvidence::Exact + } else { + AgentEvidence::None + } +} + +fn confidence_rank(value: EvidenceConfidence) -> u8 { + match value { + EvidenceConfidence::Ambiguous => 0, + EvidenceConfidence::Inferred => 1, + EvidenceConfidence::Exact => 2, + } +} + +fn resolution_rank(value: ResolutionState) -> u8 { + match value { + ResolutionState::Ambiguous => 0, + ResolutionState::Unresolved => 1, + ResolutionState::Exact => 2, + } +} + +fn agent_caveat(diagnostic: &QueryDiagnostic) -> AgentCaveat { + let (severity, statement) = match diagnostic.code { + QueryDiagnosticCode::AmbiguousMatch => ( + AgentSeverity::Blocker, + format!( + "Do not select a candidate automatically. {}", + diagnostic.message + ), + ), + QueryDiagnosticCode::NoMatch => ( + AgentSeverity::Blocker, + format!( + "Fallback candidates are suggestions, not an exact answer. {}", + diagnostic.message + ), + ), + QueryDiagnosticCode::DirectionMismatch => ( + AgentSeverity::Blocker, + format!( + "A reverse-only connection is not a valid directed path. {}", + diagnostic.message + ), + ), + QueryDiagnosticCode::StaleSourceDigest => ( + AgentSeverity::Blocker, + format!( + "Do not quote or edit the stale source excerpt. {}", + diagnostic.message + ), + ), + QueryDiagnosticCode::IncompleteCoverage => ( + AgentSeverity::Warning, + format!( + "Absence is not proof that the relationship does not exist. {}", + diagnostic.message + ), + ), + QueryDiagnosticCode::BoundedTruncation => ( + AgentSeverity::Warning, + format!( + "More retained facts may exist beyond the response bound. {}", + diagnostic.message + ), + ), + QueryDiagnosticCode::UnresolvedHandler => ( + AgentSeverity::Warning, + format!("The framework target is unresolved. {}", diagnostic.message), + ), + QueryDiagnosticCode::ProgramConflict => ( + AgentSeverity::Warning, + format!( + "Structural and Program IR evidence disagree. {}", + diagnostic.message + ), + ), + QueryDiagnosticCode::ProgramOrphan => ( + AgentSeverity::Info, + format!( + "Program evidence could not join to a graph entity. {}", + diagnostic.message + ), + ), + QueryDiagnosticCode::ProgramUnavailable => ( + AgentSeverity::Info, + format!( + "Optional Program IR evidence was unavailable. {}", + diagnostic.message + ), + ), + }; + AgentCaveat { + severity, + code: diagnostic_code_name(diagnostic.code).to_owned(), + statement, + node_id: diagnostic.node_id.clone(), + path: diagnostic.path.clone(), + } +} + +fn diagnostic_code_name(code: QueryDiagnosticCode) -> &'static str { + match code { + QueryDiagnosticCode::NoMatch => "no_match", + QueryDiagnosticCode::AmbiguousMatch => "ambiguous_match", + QueryDiagnosticCode::DirectionMismatch => "direction_mismatch", + QueryDiagnosticCode::UnresolvedHandler => "unresolved_handler", + QueryDiagnosticCode::IncompleteCoverage => "incomplete_coverage", + QueryDiagnosticCode::StaleSourceDigest => "stale_source_digest", + QueryDiagnosticCode::BoundedTruncation => "bounded_truncation", + QueryDiagnosticCode::ProgramOrphan => "program_orphan", + QueryDiagnosticCode::ProgramConflict => "program_conflict", + QueryDiagnosticCode::ProgramUnavailable => "program_unavailable", + } +} + +fn code_match_state( + context: &AgentQueryContext, + response: &CodeQueryResponse, + nodes: &BTreeMap, +) -> AgentMatch { + if has_diagnostic( + response.diagnostics.as_slice(), + QueryDiagnosticCode::AmbiguousMatch, + ) { + return AgentMatch::Ambiguous; + } + if has_diagnostic( + response.diagnostics.as_slice(), + QueryDiagnosticCode::NoMatch, + ) { + return AgentMatch::None; + } + if response.operation == CodeQueryOperation::Search { + let query = context + .operands + .iter() + .find(|operand| operand.role == AgentOperandRole::Query) + .map(|operand| operand.value.as_str()); + if query.is_some_and(|query| { + response.results.first().is_some_and(|hit| { + nodes.get(&hit.node_id).is_some_and(|node| { + node.id == query || node.name == query || node.qualified_name == query + }) + }) + }) { + AgentMatch::Exact + } else if response.results.is_empty() { + AgentMatch::None + } else { + AgentMatch::Fuzzy + } + } else if response.nodes.is_empty() { + AgentMatch::Unknown + } else { + AgentMatch::Exact + } +} + +fn code_result_state( + operation: AgentOperation, + match_state: AgentMatch, + response: &CodeQueryResponse, +) -> AgentResultState { + if match_state == AgentMatch::Ambiguous { + return AgentResultState::NeedsResolution; + } + if match_state == AgentMatch::None { + return AgentResultState::NoMatch; + } + if has_diagnostic( + response.diagnostics.as_slice(), + QueryDiagnosticCode::DirectionMismatch, + ) { + return AgentResultState::NoPath; + } + if operation == AgentOperation::NodeTrail + && response.paths.is_empty() + && !response.nodes.is_empty() + { + return AgentResultState::NoPath; + } + if operation == AgentOperation::Search && match_state == AgentMatch::Fuzzy { + AgentResultState::Candidates + } else { + AgentResultState::Answered + } +} + +fn answer_for_code( + context: &AgentQueryContext, + result_state: AgentResultState, + match_state: AgentMatch, + response: &CodeQueryResponse, + primary_results: &[AgentEntity], + relationships: &[AgentRelationship], + paths: &[AgentPath], +) -> AgentAnswer { + let requested = context + .operands + .first() + .map(|operand| operand.value.clone()) + .unwrap_or_else(|| "the requested query".to_owned()); + let subject = primary_results + .first() + .map(|entity| entity.label.clone()) + .unwrap_or_else(|| requested.clone()); + let headline = match context.operation { + AgentOperation::Search => match result_state { + AgentResultState::NoMatch => { + format!("No exact match for \"{requested}\"; fallback candidates are shown.") + } + AgentResultState::Candidates => format!( + "Found {} candidate matches for \"{requested}\".", + primary_results.len() + ), + _ if match_state == AgentMatch::Exact => { + format!("Found an exact match for \"{requested}\".") + } + _ => format!("No exact answer was proven for \"{requested}\"."), + }, + AgentOperation::Callers => format!( + "Found {} incoming call or route relationship(s) for {subject}.", + relationships.len() + ), + AgentOperation::Callees => format!( + "Found {} direct callee relationship(s) for {subject}.", + relationships.len() + ), + AgentOperation::Impact => format!( + "Found {} potentially affected node(s) within depth {}.", + primary_results.len(), + response.limits.max_depth + ), + AgentOperation::Explore => format!( + "Found {} candidate anchor(s) and {} relationship(s) for the question.", + primary_results.len(), + relationships.len() + ), + AgentOperation::NodeTrail => { + let source = context + .operands + .iter() + .find(|operand| operand.role == AgentOperandRole::Source) + .map(|operand| operand.value.as_str()) + .unwrap_or("source"); + let target = context + .operands + .iter() + .find(|operand| operand.role == AgentOperandRole::Target) + .map(|operand| operand.value.as_str()) + .unwrap_or("target"); + if let Some(path) = paths.first() { + format!( + "Found a {}-hop directed path from {source} to {target}.", + path.steps.len() + ) + } else { + "No directed path reaches the exact target within the requested bounds.".to_owned() + } + } + AgentOperation::Discovery => "Discovery returned candidate anchors.".to_owned(), + }; + let mut basis = Vec::new(); + if let Some(entity) = primary_results.first() { + basis.push(AgentBasis { + kind: "node".to_owned(), + id: entity.id.clone(), + }); + } else if let Some(relationship) = relationships.first() { + basis.push(AgentBasis { + kind: "relationship".to_owned(), + id: relationship.id.clone(), + }); + } else if let Some(path) = paths.first() { + basis.push(AgentBasis { + kind: "path".to_owned(), + id: path.id.clone(), + }); + } else { + basis.push(AgentBasis { + kind: "operation".to_owned(), + id: context.operation.label().to_owned(), + }); + } + AgentAnswer { headline, basis } +} + +fn answer_for_discovery( + result_state: AgentResultState, + question: &str, + seeds: usize, + relationships: usize, + primary_results: &[AgentEntity], +) -> AgentAnswer { + let headline = match result_state { + AgentResultState::NoMatch => { + format!("No exact match for \"{question}\"; fallback candidates are shown.") + } + AgentResultState::NeedsResolution => { + format!("Multiple candidate matches remain for \"{question}\"; choose an exact target.") + } + _ => format!( + "Found {seeds} candidate anchor(s) and {relationships} relationship(s) for the question." + ), + }; + let basis = primary_results + .first() + .map(|entity| AgentBasis { + kind: "node".to_owned(), + id: entity.id.clone(), + }) + .unwrap_or_else(|| AgentBasis { + kind: "operation".to_owned(), + id: "discovery".to_owned(), + }); + AgentAnswer { + headline, + basis: vec![basis], + } +} + +fn next_actions_for_code( + context: &AgentQueryContext, + primary_results: &[AgentEntity], + caveats: &[AgentCaveat], + source_truncated: bool, + paths: &[QueryPath], +) -> Vec { + let mut actions = Vec::new(); + if caveats + .iter() + .any(|caveat| caveat.code == "ambiguous_match") + { + for entity in primary_results.iter().take(3) { + actions.push(AgentNextAction { + kind: "retry_with_exact_id".to_owned(), + reason: "Resolve the ambiguous symbol without selecting by position.".to_owned(), + cli: Some(AgentActionCli { + argv: vec!["compass".to_owned(), "search".to_owned(), entity.id.clone()], + }), + mcp: Some(AgentActionMcp { + tool: "search_symbols".to_owned(), + arguments: Map::from_iter([("query".to_owned(), json!(entity.id))]), + }), + }); + } + } + if let Some(entity) = primary_results.first() + && !caveats + .iter() + .any(|caveat| caveat.code == "ambiguous_match") + { + actions.push(AgentNextAction { + kind: "inspect_target".to_owned(), + reason: "Inspect the exact retained target with source-grounded context.".to_owned(), + cli: Some(AgentActionCli { + argv: vec![ + "compass".to_owned(), + "context".to_owned(), + "explain".to_owned(), + entity.id.clone(), + ], + }), + mcp: Some(AgentActionMcp { + tool: "task_context".to_owned(), + arguments: Map::from_iter([ + ("intent".to_owned(), json!("explain")), + ("target".to_owned(), json!(entity.id)), + ]), + }), + }); + } + if source_truncated && paths.is_empty() { + actions.push(AgentNextAction { + kind: "narrow_query".to_owned(), + reason: "The source query reached a bound before retaining every fact.".to_owned(), + cli: None, + mcp: None, + }); + } + if context.evidence_hidden { + actions.push(AgentNextAction { + kind: "show_evidence".to_owned(), + reason: "Provenance records were hidden by the request.".to_owned(), + cli: None, + mcp: None, + }); + } + actions +} + +fn next_actions_for_discovery( + context: &AgentQueryContext, + primary_results: &[AgentEntity], + source_truncated: bool, +) -> Vec { + let mut actions = Vec::new(); + if let Some(cursor) = &context.continuation_cursor { + actions.push(AgentNextAction { + kind: "continue_result".to_owned(), + reason: "Continue the bounded discovery result at its next page.".to_owned(), + cli: Some(AgentActionCli { + argv: vec![ + "compass".to_owned(), + "query".to_owned(), + "--cursor".to_owned(), + cursor.clone(), + ], + }), + mcp: None, + }); + } + if let Some(entity) = primary_results.first() { + actions.push(AgentNextAction { + kind: "inspect_target".to_owned(), + reason: "Inspect the strongest retained discovery anchor.".to_owned(), + cli: None, + mcp: Some(AgentActionMcp { + tool: "task_context".to_owned(), + arguments: Map::from_iter([ + ("intent".to_owned(), json!("explain")), + ("target".to_owned(), json!(entity.id)), + ]), + }), + }); + } + if source_truncated { + actions.push(AgentNextAction { + kind: "narrow_query".to_owned(), + reason: "Discovery reached a source bound.".to_owned(), + cli: None, + mcp: None, + }); + } + actions +} + +fn has_diagnostic(diagnostics: &[QueryDiagnostic], code: QueryDiagnosticCode) -> bool { + diagnostics.iter().any(|diagnostic| diagnostic.code == code) +} + +fn view_digest(view: &AgentQueryView) -> Result { + let mut canonical = view.clone(); + canonical.identity.view_digest.clear(); + let bytes = serde_json::to_vec(&canonical).map_err(|error| invalid(error.to_string()))?; + Ok(format!("sha256:{:x}", Sha256::digest(bytes))) +} + +fn valid_sha256(value: &str) -> bool { + let Some(digest) = value.strip_prefix("sha256:") else { + return false; + }; + digest.len() == 64 + && digest + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} diff --git a/crates/compass-output/src/lib.rs b/crates/compass-output/src/lib.rs index 99e74fa23..c9584da7c 100644 --- a/crates/compass-output/src/lib.rs +++ b/crates/compass-output/src/lib.rs @@ -1,5 +1,6 @@ //! Safe, deterministic output formats for Compass graphs. +mod agent_query; mod architecture_projection; mod backup; mod callflow; @@ -21,6 +22,18 @@ mod viewer_model; mod wiki; mod workbench; +pub use agent_query::{ + AGENT_QUERY_VIEW_SCHEMA, AGENT_VIEW_MAX_BYTES, AGENT_VIEW_MAX_CAVEATS, + AGENT_VIEW_MAX_NEXT_ACTIONS, AGENT_VIEW_MAX_PATHS, AGENT_VIEW_MAX_PRIMARY_RESULTS, + AGENT_VIEW_MAX_RELATIONSHIPS, AGENT_VIEW_MAX_SCALAR_CHARS, AGENT_VIEW_TEXT_MAX_BYTES, + AgentActionCli, AgentActionMcp, AgentAnswer, AgentBasis, AgentCaveat, AgentCoverage, + AgentEndpoint, AgentEntity, AgentEvidence, AgentExecution, AgentIdentity, AgentMatch, + AgentNextAction, AgentOmissions, AgentOperand, AgentOperandRole, AgentOperation, AgentPath, + AgentPathDirection, AgentPathStep, AgentProjection, AgentQueryContext, AgentQueryView, + AgentRelationship, AgentRelationshipEvidence, AgentRequest, AgentResultState, AgentSeverity, + AgentSource, AgentStatus, build_code_query_view, build_discovery_query_view, + render_agent_query_header_lines, render_agent_query_text, +}; pub use architecture_projection::{ ARCHITECTURE_OVERLAY_SCHEMA, ARCHITECTURE_VIEWER_SCHEMA, ArchitectureClassCounts, ArchitectureCoverage, ArchitectureDiagnosticSeverity, ArchitectureEvidenceCounts, @@ -105,6 +118,10 @@ pub enum OutputError { ReviewBudgetExceeded { rendered_bytes: usize, limit: usize }, #[error("invalid PR review output: {0}")] InvalidReview(String), + #[error("invalid agent query view: {0}")] + InvalidAgentQuery(String), + #[error("agent query view text is {rendered_bytes} bytes; limit is {limit}")] + AgentQueryTextBudgetExceeded { rendered_bytes: usize, limit: usize }, #[error(transparent)] Review(#[from] compass_pr_intelligence::PrIntelligenceError), #[error("invalid orientation model: {reason}")] diff --git a/crates/compass-output/tests/agent_query.rs b/crates/compass-output/tests/agent_query.rs new file mode 100644 index 000000000..d4f77c7f3 --- /dev/null +++ b/crates/compass-output/tests/agent_query.rs @@ -0,0 +1,265 @@ +use std::error::Error; + +use compass_model::code_graph::{EdgeKind, NodeKind}; +use compass_model::provenance::{ + EvidenceConfidence, EvidenceOrigin, ResolutionState, SourceAnchor, +}; +use compass_model::query_contract::{ + CodeQueryLimits, CodeQueryOperation, CodeQueryResponse, QueryDiagnostic, QueryDiagnosticCode, + QueryEdge, QueryEvidence, QueryEvidenceLayer, QueryNode, QueryPath, SearchHit, +}; +use compass_output::{ + AgentEvidence, AgentExecution, AgentMatch, AgentOperation, AgentQueryContext, AgentResultState, + build_code_query_view, render_agent_query_text, +}; + +fn anchor(file: &str, line: u32) -> SourceAnchor { + SourceAnchor { + file: file.to_owned(), + start_byte: 0, + end_byte: 4, + start_line: line, + start_column: 0, + end_line: line, + end_column: 4, + } +} + +fn evidence(source: &SourceAnchor) -> QueryEvidence { + QueryEvidence { + layer: QueryEvidenceLayer::StructuralGraph, + origin: EvidenceOrigin::Ast, + extractor: "agent-query-test".to_owned(), + confidence: EvidenceConfidence::Exact, + anchor: Some(source.clone()), + rule: None, + wiring_site: None, + resolution: ResolutionState::Exact, + candidates: Vec::new(), + } +} + +fn node(id: &str, label: &str, source: &SourceAnchor) -> QueryNode { + QueryNode { + id: id.to_owned(), + kind: NodeKind::Function, + roles: Vec::new(), + name: label.to_owned(), + qualified_name: format!("Fixture.{label}"), + language: Some("rust".to_owned()), + framework: None, + source: Some(source.clone()), + details: None, + evidence: vec![evidence(source)], + } +} + +fn response(operation: CodeQueryOperation) -> CodeQueryResponse { + CodeQueryResponse::empty(operation, CodeQueryLimits::default()) +} + +fn context(operation: AgentOperation) -> AgentQueryContext { + AgentQueryContext::new(operation, "graph-identity", "generation-identity") +} + +#[test] +fn exact_relationship_view_is_answer_first_and_round_trips() -> Result<(), Box> { + let caller_anchor = anchor("src/caller.rs", 10); + let target_anchor = anchor("src/target.rs", 20); + let mut response = response(CodeQueryOperation::Callers); + response.nodes = vec![ + node("n:caller", "Caller", &caller_anchor), + node("n:target", "Target", &target_anchor), + ]; + response.edges.push(QueryEdge { + id: "e:caller-target".to_owned(), + source: "n:caller".to_owned(), + target: "n:target".to_owned(), + kind: EdgeKind::Calls, + relationship_site: Some(caller_anchor), + details: None, + evidence: vec![evidence(&target_anchor)], + }); + let view = build_code_query_view( + &response, + context(AgentOperation::Callers) + .with_operand(compass_output::AgentOperandRole::Symbol, "Target"), + )?; + assert_eq!(view.status.result_state, AgentResultState::Answered); + assert_eq!(view.status.match_state, AgentMatch::Exact); + assert_eq!(view.status.evidence_state, AgentEvidence::Exact); + assert_eq!(view.status.source_execution, AgentExecution::Complete); + assert_eq!(view.relationships[0].source.label, "Fixture.Caller"); + assert_eq!(view.relationships[0].target.label, "Fixture.Target"); + let text = render_agent_query_text(&view)?; + assert!(text.starts_with("RESULT\n")); + assert!( + text.find("ANSWER").ok_or("missing answer")? + < text + .find("PRIMARY RESULTS") + .ok_or("missing primary results")? + ); + assert!(text.contains("Fixture.Caller --calls--> Fixture.Target")); + let json = serde_json::to_vec(&view)?; + assert_eq!(compass_output::AgentQueryView::from_json(&json)?, view); + Ok(()) +} + +#[test] +fn no_match_and_ambiguous_results_are_not_positive_answers() -> Result<(), Box> { + let mut response = response(CodeQueryOperation::Search); + response.diagnostics.push(QueryDiagnostic { + code: QueryDiagnosticCode::NoMatch, + message: "NO EXACT MATCH for Targat".to_owned(), + node_id: None, + path: None, + }); + let view = build_code_query_view( + &response, + context(AgentOperation::Search) + .with_operand(compass_output::AgentOperandRole::Query, "Targat"), + )?; + assert_eq!(view.status.result_state, AgentResultState::NoMatch); + assert_eq!(view.status.match_state, AgentMatch::None); + assert!(view.answer.headline.starts_with("No exact match")); + assert!(view.caveats.iter().any(|caveat| caveat.code == "no_match")); + let text = render_agent_query_text(&view)?; + assert!( + text.find("CAVEATS").ok_or("missing caveats")? + < text + .find("PRIMARY RESULTS") + .ok_or("missing primary results")? + ); + Ok(()) +} + +#[test] +fn unresolved_ambiguity_without_retained_candidates_stays_explicit() -> Result<(), Box> { + let mut response = response(CodeQueryOperation::Callers); + response.diagnostics.push(QueryDiagnostic { + code: QueryDiagnosticCode::AmbiguousMatch, + message: "Symbol Target matched multiple nodes".to_owned(), + node_id: None, + path: None, + }); + let view = build_code_query_view( + &response, + context(AgentOperation::Callers) + .with_operand(compass_output::AgentOperandRole::Symbol, "Target"), + )?; + assert_eq!(view.status.result_state, AgentResultState::NeedsResolution); + assert!(view.primary_results.is_empty()); + assert!( + view.answer + .basis + .iter() + .any(|basis| basis.kind == "operation") + ); + Ok(()) +} + +#[test] +fn reverse_path_keeps_published_direction_visible() -> Result<(), Box> { + let source_anchor = anchor("src/source.rs", 1); + let target_anchor = anchor("src/target.rs", 2); + let mut response = response(CodeQueryOperation::NodeTrail); + response.nodes = vec![ + node("n:source", "Source", &source_anchor), + node("n:target", "Target", &target_anchor), + ]; + response.edges.push(QueryEdge { + id: "e:source-target".to_owned(), + source: "n:source".to_owned(), + target: "n:target".to_owned(), + kind: EdgeKind::Calls, + relationship_site: Some(source_anchor.clone()), + details: None, + evidence: vec![evidence(&source_anchor)], + }); + response.paths.push(QueryPath { + id: "p:reverse".to_owned(), + node_ids: vec!["n:target".to_owned(), "n:source".to_owned()], + edge_ids: vec!["e:source-target".to_owned()], + weakest_resolution: ResolutionState::Exact, + weakest_confidence: EvidenceConfidence::Exact, + }); + let view = build_code_query_view( + &response, + context(AgentOperation::NodeTrail) + .with_operand(compass_output::AgentOperandRole::Source, "Source") + .with_operand(compass_output::AgentOperandRole::Target, "Target"), + )?; + assert_eq!(view.status.result_state, AgentResultState::Answered); + assert_eq!( + view.paths[0].steps[0].direction, + compass_output::AgentPathDirection::Reverse + ); + Ok(()) +} + +#[test] +fn direction_mismatch_is_a_no_path_blocker() -> Result<(), Box> { + let source_anchor = anchor("src/source.rs", 1); + let target_anchor = anchor("src/target.rs", 2); + let mut response = response(CodeQueryOperation::NodeTrail); + response.nodes = vec![ + node("n:source", "Source", &source_anchor), + node("n:target", "Target", &target_anchor), + ]; + response.diagnostics.push(QueryDiagnostic { + code: QueryDiagnosticCode::DirectionMismatch, + message: "A reverse-only connection was found".to_owned(), + node_id: Some("n:source".to_owned()), + path: None, + }); + let view = build_code_query_view( + &response, + context(AgentOperation::NodeTrail) + .with_operand(compass_output::AgentOperandRole::Source, "Source") + .with_operand(compass_output::AgentOperandRole::Target, "Target"), + )?; + assert_eq!(view.status.result_state, AgentResultState::NoPath); + assert!( + view.caveats + .iter() + .any(|caveat| caveat.code == "direction_mismatch") + ); + Ok(()) +} + +#[test] +fn equivalent_collection_order_has_one_view_digest() -> Result<(), Box> { + let first_anchor = anchor("src/first.rs", 1); + let second_anchor = anchor("src/second.rs", 2); + let mut left = response(CodeQueryOperation::Search); + left.nodes = vec![ + node("n:first", "First", &first_anchor), + node("n:second", "Second", &second_anchor), + ]; + left.results.push(SearchHit { + node_id: "n:first".to_owned(), + score: 1.0, + matched_fields: vec!["name".to_owned()], + }); + let mut right = left.clone(); + right.nodes.reverse(); + let left_view = build_code_query_view( + &left, + context(AgentOperation::Search) + .with_operand(compass_output::AgentOperandRole::Query, "First"), + )?; + let right_view = build_code_query_view( + &right, + context(AgentOperation::Search) + .with_operand(compass_output::AgentOperandRole::Query, "First"), + )?; + assert_eq!( + left_view.identity.source_result_digest, + right_view.identity.source_result_digest + ); + assert_eq!( + left_view.identity.view_digest, + right_view.identity.view_digest + ); + Ok(()) +} diff --git a/crates/compass-query/src/discovery_text.rs b/crates/compass-query/src/discovery_text.rs index cee95f01b..1078281c4 100644 --- a/crates/compass-query/src/discovery_text.rs +++ b/crates/compass-query/src/discovery_text.rs @@ -123,6 +123,28 @@ struct CursorEnvelope { pub fn render_discovery_text_page( response: &DiscoveryQueryResponse, options: DiscoveryTextPageOptions<'_>, +) -> Result { + render_discovery_text_page_internal(response, options, None) +} + +/// Render a discovery page using caller-owned, already escaped prefix lines. +/// +/// Cursor validation and the ordered entry ledger remain owned by this crate; +/// the prefix is presentation-only and therefore cannot change a v2 cursor's +/// semantic position. The ordinary renderer above keeps the historical prefix +/// for library callers that do not opt into the Agent View. +pub fn render_discovery_text_page_with_prefix( + response: &DiscoveryQueryResponse, + options: DiscoveryTextPageOptions<'_>, + prefix: &[String], +) -> Result { + render_discovery_text_page_internal(response, options, Some(prefix)) +} + +fn render_discovery_text_page_internal( + response: &DiscoveryQueryResponse, + options: DiscoveryTextPageOptions<'_>, + prefix: Option<&[String]>, ) -> Result { if !(MIN_TEXT_BUDGET..=MAX_TEXT_BUDGET).contains(&options.token_budget) { return Err(DiscoveryTextPageError::InvalidBudget); @@ -153,69 +175,10 @@ pub fn render_discovery_text_page( } None => 0, }; - let ambiguity = response.seeds.iter().filter(|seed| seed.ambiguous).count(); - let term_selection = crate::text::discovery_term_selection(&response.question); - let mut fixed = match_signal_lines(response); - fixed.push(format!( - "Seed terms: {}{}", - if term_selection.ranking_terms.is_empty() { - "none".to_owned() - } else { - rendered_values(&term_selection.ranking_terms) - }, - if term_selection.discarded_generic_terms.is_empty() { - String::new() - } else { - format!( - " (discarded as too generic: {})", - rendered_quoted_values(&term_selection.discarded_generic_terms) - ) - } - )); - fixed.extend([ - format!( - "Discovery: {} seed(s), {} node(s), {} edge(s)", - response.seeds.len(), - response.nodes.len(), - response.edges.len() - ), - format!( - "Direction: {} ({})", - direction_name(response.selected_direction), - direction_source_name(response.direction_source) - ), - format!("Ambiguity: {ambiguity} ambiguous seed(s)"), - format!( - "Graph coverage: {}", - if response - .diagnostics - .iter() - .any(|diagnostic| diagnostic.code == QueryDiagnosticCode::IncompleteCoverage) - { - "incomplete (incomplete_coverage diagnostic)" - } else { - "no incompleteness reported (coverage otherwise unknown)" - }, - ), - format!( - "Domain result: {} (domainTruncated={})", - if response.truncated { - "partial" - } else { - "complete" - }, - response.truncated - ), - format!("Traversal: {}", traversal_name(response.traversal)), - format!( - "Relationship contexts: {}", - rendered_values(&response.relation_contexts) - ), - format!("Scope (OR): {}", rendered_scopes(response)), - ]); - if options.include_evidence { - fixed.push(format!("Semantic result: sha256:{semantic_result_digest}")); - } + let fixed = prefix.map_or_else( + || default_fixed_lines(response, options.include_evidence, &semantic_result_digest), + ToOwned::to_owned, + ); let max_chars = options.token_budget.saturating_mul(4); let fixed_chars = fixed .iter() @@ -288,6 +251,77 @@ pub fn render_discovery_text_page( }) } +fn default_fixed_lines( + response: &DiscoveryQueryResponse, + include_evidence: bool, + semantic_result_digest: &str, +) -> Vec { + let ambiguity = response.seeds.iter().filter(|seed| seed.ambiguous).count(); + let term_selection = crate::text::discovery_term_selection(&response.question); + let mut fixed = match_signal_lines(response); + fixed.push(format!( + "Seed terms: {}{}", + if term_selection.ranking_terms.is_empty() { + "none".to_owned() + } else { + rendered_values(&term_selection.ranking_terms) + }, + if term_selection.discarded_generic_terms.is_empty() { + String::new() + } else { + format!( + " (discarded as too generic: {})", + rendered_quoted_values(&term_selection.discarded_generic_terms) + ) + } + )); + fixed.extend([ + format!( + "Discovery: {} seed(s), {} node(s), {} edge(s)", + response.seeds.len(), + response.nodes.len(), + response.edges.len() + ), + format!( + "Direction: {} ({})", + direction_name(response.selected_direction), + direction_source_name(response.direction_source) + ), + format!("Ambiguity: {ambiguity} ambiguous seed(s)"), + format!( + "Graph coverage: {}", + if response + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == QueryDiagnosticCode::IncompleteCoverage) + { + "incomplete (incomplete_coverage diagnostic)" + } else { + "no incompleteness reported (coverage otherwise unknown)" + }, + ), + format!( + "Domain result: {} (domainTruncated={})", + if response.truncated { + "partial" + } else { + "complete" + }, + response.truncated + ), + format!("Traversal: {}", traversal_name(response.traversal)), + format!( + "Relationship contexts: {}", + rendered_values(&response.relation_contexts) + ), + format!("Scope (OR): {}", rendered_scopes(response)), + ]); + if include_evidence { + fixed.push(format!("Semantic result: sha256:{semantic_result_digest}")); + } + fixed +} + fn continuation_cursor( entries: &[Entry], offset: usize, diff --git a/crates/compass-query/src/lib.rs b/crates/compass-query/src/lib.rs index 97531cb06..7e8757087 100644 --- a/crates/compass-query/src/lib.rs +++ b/crates/compass-query/src/lib.rs @@ -1,5 +1,7 @@ //! Native graph search, traversal, explanation, and impact analysis. +use sha2::Digest as _; + mod affected; mod benchmark; mod bm25; @@ -31,6 +33,7 @@ pub use discovery_text::{ DEFAULT_DISCOVERY_TEXT_TOKEN_BUDGET, DISCOVERY_TEXT_PAGE_VERSION, DiscoveryTextPage, DiscoveryTextPageError, DiscoveryTextPageOptions, discovery_request_digest, discovery_response_digest, discovery_result_envelope, render_discovery_text_page, + render_discovery_text_page_with_prefix, }; pub use graph_engine::{ DirectGraphEngine, EffectiveGraphEngine, GraphEngine, JsonGraphEngine, StoreGraphEngine, @@ -71,11 +74,30 @@ pub use traversal::{ render_shortest_path, render_shortest_path_with_limit, }; +/// Return the canonical semantic-result digest for a typed code query. +/// +/// The digest deliberately lives beside the query contract rather than in a +/// presentation layer. Equivalent responses with different collection order +/// therefore bind to one source result, while any semantic field retained by +/// `CodeQueryResponse` changes the digest. +pub fn code_query_response_digest( + response: &compass_model::query_contract::CodeQueryResponse, +) -> Result { + let mut canonical = response.clone(); + canonical.sort_stable(); + let bytes = serde_json::to_vec(&canonical)?; + Ok(format!("{:x}", sha2::Sha256::digest(bytes))) +} + #[cfg(test)] mod tests { use std::collections::HashMap; use std::error::Error; + use compass_model::query_contract::{ + CodeQueryLimits, CodeQueryOperation, CodeQueryResponse, QueryDiagnostic, + QueryDiagnosticCode, SearchHit, + }; use compass_model::{Graph, GraphDocument}; use super::*; @@ -85,6 +107,52 @@ mod tests { Ok(Graph::from_document(document)?) } + #[test] + fn code_query_response_digest_is_order_independent_but_semantic_sensitive() + -> Result<(), Box> { + let mut left = + CodeQueryResponse::empty(CodeQueryOperation::Search, CodeQueryLimits::default()); + left.results = vec![ + SearchHit { + node_id: "node:b".to_owned(), + score: 0.4, + matched_fields: vec!["name".to_owned()], + }, + SearchHit { + node_id: "node:a".to_owned(), + score: 0.9, + matched_fields: vec!["qualified_name".to_owned()], + }, + ]; + left.diagnostics.push(QueryDiagnostic { + code: QueryDiagnosticCode::BoundedTruncation, + message: "bounded".to_owned(), + node_id: None, + path: None, + }); + let mut right = left.clone(); + right.results.reverse(); + right.diagnostics.reverse(); + assert_eq!( + code_query_response_digest(&left)?, + code_query_response_digest(&right)? + ); + + let mut changed = right.clone(); + changed.truncated = true; + assert_ne!( + code_query_response_digest(&left)?, + code_query_response_digest(&changed)? + ); + changed.truncated = left.truncated; + changed.limits.max_nodes = left.limits.max_nodes.saturating_add(1); + assert_ne!( + code_query_response_digest(&left)?, + code_query_response_digest(&changed)? + ); + Ok(()) + } + #[test] fn query_terms_remove_question_noise() { assert_eq!( diff --git a/docs/guides/exploring-a-codebase.md b/docs/guides/exploring-a-codebase.md index 1e0ad5a75..86e4dafcd 100644 --- a/docs/guides/exploring-a-codebase.md +++ b/docs/guides/exploring-a-codebase.md @@ -118,6 +118,22 @@ If the result is broad, add the boundary or action you care about: "API token verification failure" behavior-shaped ``` +Read an answer-first result in this order: + +1. `RESULT` — decide whether the operation answered, returned candidates, + needs resolution, has no exact match, or has no path. +2. `ANSWER` and `CAVEATS` — understand the bounded conclusion and any blocker + before looking at graph records. +3. `PRIMARY RESULTS`, `PATHS`, and `RELATIONSHIPS` — verify IDs, direction, + and source locations. +4. `NEXT ACTIONS` — follow an exact ID retry, evidence lookup, or continuation + cursor instead of reconstructing a command from prose. + +For automation, request `--format agent-json` and validate +`compass.query.agent-view/1`. Keep `--format json` for the complete raw +evidence contract. A fallback candidate is a lead, not proof of an exact +answer; a partial execution or unknown coverage state must be disclosed. + Save useful output when comparing questions: ```bash diff --git a/docs/guides/integrating-compass.md b/docs/guides/integrating-compass.md index 23b809a8b..1734e7922 100644 --- a/docs/guides/integrating-compass.md +++ b/docs/guides/integrating-compass.md @@ -23,6 +23,28 @@ boundaries and failure-safe consumption. Human text is optimized for clarity and can evolve. Machine consumers should prefer explicitly versioned JSON or documented graph schemas. +### Agent View for coding assistants + +For focused code questions, use the Agent View projection when the consumer +must make a follow-up decision: + +```bash +compass callers PaymentService.charge --format agent-json +compass query "who calls PaymentService.charge?" --format agent-json +``` + +The first fields to inspect are `status`, `answer`, and `caveats`. A +`no_match`, `needs_resolution`, or `no_path` state is not a positive answer; +fallback candidates remain suggestions. Check `sourceExecution` and +`projection` before claiming completeness, and use `nextActions` when an exact +retry or evidence lookup is suggested. `identity.sourceResultDigest` binds the +projection to the raw result and `identity.viewDigest` detects mutation. + +Use `--format json` when you need the complete raw `compass.query/1` response, +or when a consumer needs evidence fields omitted by the bounded view. Human +text follows the same order (`RESULT`, `ANSWER`, `CAVEATS`, then details), but +headings are presentation and are not a machine schema. + ## Integration pattern: produce, validate, publish, consume Treat a graph build as a producer job: @@ -211,6 +233,13 @@ Before placing it behind an editor or network service: For a local coding assistant, stdio avoids opening a listening socket. Use HTTP only when a multi-process or remote integration actually needs it. +Typed query tools return answer-first Agent View text. Their structured result +keeps the raw response in `result`, adds `semanticResultDigest`, and carries +the optional `agentView` sibling with schema `compass.query.agent-view/1`. +Consumers that do not use Agent View can ignore that optional sibling while +continuing to validate the transport envelope and raw result. Consumers that +do use it must reject unknown Agent View major versions explicitly. + ## Cross-repository registry Compass can register graphs in a global index: diff --git a/docs/implementation/query-engine.md b/docs/implementation/query-engine.md index 2ec3897f9..37486f691 100644 --- a/docs/implementation/query-engine.md +++ b/docs/implementation/query-engine.md @@ -424,6 +424,31 @@ Renderers produce: Output-to-file uses atomic completion. A failed execution must not leave a valid-looking partial result. +## Agent-readable projection + +`compass-output` owns the presentation-only `compass.query.agent-view/1` +projection. The query engine supplies the full raw response and canonical +source-result digest; it does not select a different node or re-run resolution +for presentation. The projector records the invocation operands and graph / +generation identities, then derives deterministic status dimensions: + +- result (`answered`, `candidates`, `needs_resolution`, `no_match`, `no_path`); +- match and evidence state; +- source execution versus projection truncation; +- explicit corpus coverage (`incomplete` or `unknown`). + +Relationships inline both endpoint IDs and labels, and path steps retain the +published edge direction (`forward` or `reverse`). Diagnostics become sorted +caveats before graph detail. Stable bounds (12 primary results, 24 +relationships, 5 paths, 16 caveats, 5 next actions, 256 KiB JSON, 64 KiB +text) keep the projection safe for tool transports. `identity.viewDigest` +covers the projection without mutating the raw result. + +CLI and MCP call the same projector and text renderer. Raw `json` output and +MCP `structuredContent.result` remain authoritative and unchanged. The +discovery page renderer accepts an already escaped fixed header but keeps its +`compass.query.discovery-text-page/2` entry ledger and cursor semantics. + ## Explain and profile `EXPLAIN` returns logical operators and optimization records without executing. diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 5758e5ac2..5d360d528 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -392,6 +392,34 @@ example `RETURN n.id ORDER BY n.id SKIP 100 LIMIT 100`. Canonical language contract: [CompassQL](../COMPASSQL.md). +### Typed query commands + +The focused typed commands share one output profile: + +```text +compass ask "" [--format text|agent-json|json] +compass search "" [--format text|agent-json|json] +compass callers "" [--format text|agent-json|json] +compass callees "" [--format text|agent-json|json] +compass impact "" [--format text|agent-json|json] +compass explore "" ... [--format text|agent-json|json] +compass node "" "" [--format text|agent-json|json] +``` + +`text` is the answer-first Agent View projection. It starts with `RESULT`, +`ANSWER`, and any blocking `CAVEATS`, then shows source-located entities, +paths, relationships, and bounded next actions. `agent-json` emits the strict +`compass.query.agent-view/1` object. `json` remains the unchanged raw +`compass.query/1` response and is the right choice when an audit consumer needs +every evidence record. The natural `query` command accepts the same +`agent-json` format for discovery; its text header is answer-first while the +existing discovery entry ledger and v2 cursor remain unchanged. + +`agent-json` is incompatible with text-only `--cursor`, `--text-budget`, +`--evidence`, and `--result-envelope` controls. Agent View JSON contains +bounded `nextActions` as argv arrays or JSON argument objects; clients should +use those values instead of reconstructing shell commands from result text. + ### `path` ```text diff --git a/docs/reference/outputs.md b/docs/reference/outputs.md index 3a2e3e597..0e8bc5221 100644 --- a/docs/reference/outputs.md +++ b/docs/reference/outputs.md @@ -502,6 +502,40 @@ When exact automation is required, use: - diff JSON; - direct graph JSON. +### Agent Query View + +The focused query commands and MCP query tools also expose the strict, +bounded projection `compass.query.agent-view/1`. It is intended for coding +agents that need to decide whether a result is usable before reading all graph +evidence. The projection is derived from the authoritative raw response; it +does not run another resolver or change ranking, direction, provenance, or +limits. + +```text +RESULT +ANSWER +CAVEATS +PRIMARY RESULTS +PATHS +RELATIONSHIPS +NEXT ACTIONS +DETAILS +``` + +The JSON form has `status.resultState` (`answered`, `candidates`, +`needs_resolution`, `no_match`, or `no_path`), separate match/evidence and +execution states, explicit caveats, full stable IDs, source locations, and +`identity.sourceResultDigest` plus `identity.viewDigest`. A no-match or +ambiguous response is never presented as a positive answer. `coverage` is +`incomplete` only when the raw query says so; otherwise it is `unknown`. + +The fixed presentation profile retains at most 12 primary results, 24 +relationships, 5 paths, 16 caveats, and 5 next actions. Serialized JSON is +limited to 256 KiB and text to 64 KiB. `omissions` and +`projectionTruncated` make projection loss explicit; raw JSON remains the +complete audit result. Human text may evolve, so automation should consume +Agent View JSON or the raw versioned response rather than parse headings. + ## CompassQL JSON Schema: