Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/semantic-search-scope-reporting.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---

Report what `semantic_search` actually covered. It names the indexed root it searched, and an empty result now says whether the index was complete, still building, disabled, or failed, so a miss is no longer mistaken for code that does not exist.
55 changes: 55 additions & 0 deletions packages/opencode/src/kilocode/tool/semantic-search-output.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { IndexingStatus } from "@kilocode/kilo-indexing/status"

/**
* Output text for `semantic_search`.
*
* Split from the tool so it can be exercised without booting the indexing
* worker: the wording is the whole point of the behavior, not an incidental
* detail of it.
*/

export function normalizePath(value: string): string {
return value.replaceAll("\\", "/")
}

/** Human-readable description of what was actually searched. */
export function scope(root: string, prefix?: string): string {
return prefix ? `${root}/${normalizePath(prefix)}` : root
}

/**
* Explain an empty result set in terms of index state.
*
* `KiloIndexing.search` returns `[]` when the index is disabled, unbuilt, or
* broken, which is indistinguishable from a genuine miss. Left unexplained, a
* model reads "no results" as "this code does not exist" and acts on it.
*/
export function reason(status?: IndexingStatus): string {
if (!status) return "The index could not be queried, so this is not evidence that no matching code exists."
const detail = status.message.trim()
const suffix = detail ? ` ${detail}` : ""
if (status.state === "Disabled") {
return `Codebase indexing is disabled for this project, so nothing was searched.${suffix}`
}
if (status.state === "Error") return `Codebase indexing failed, so nothing was searched.${suffix}`
if (status.state === "In Progress") {
return `The index is still building (${status.percent}%, ${status.processedFiles}/${status.totalFiles} files), so results are incomplete.`
}
if (status.state === "Standby") return `The index is not active, so results are incomplete.${suffix}`
return "The index is up to date, so no semantically similar code exists in this scope."
}

/**
* Full output for a search that matched nothing.
*
* Always names the indexed root: only one root is indexed, but files from other
* editor workspace folders are mentionable, so the caller needs to know that a
* miss here does not cover them.
*/
export function empty(query: string, root: string, prefix?: string, status?: IndexingStatus): string {
return [
`No results for "${query}" in ${scope(root, prefix)}.`,
reason(status),
`Only ${root} is indexed. Files in other workspace folders are not searchable here — use Read with an absolute path.`,
].join("\n")
}
28 changes: 21 additions & 7 deletions packages/opencode/src/kilocode/tool/semantic-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import path from "path"
import * as Tool from "@/tool/tool"
import { KiloIndexing } from "@/kilocode/indexing"
import { Instance } from "@/kilocode/instance"
import { empty, normalizePath, scope } from "./semantic-search-output"

import DESCRIPTION from "./semantic-search.txt"

Expand All @@ -12,7 +13,7 @@ const Parameters = Schema.Struct({
}),
path: Schema.optional(Schema.String).annotate({
description:
"Limit search to specific subdirectory (relative to the current workspace directory). Leave empty for entire workspace.",
"Limit search to a subdirectory, relative to the indexed root. Leave empty to search the whole indexed root.",
}),
})

Expand All @@ -26,6 +27,10 @@ type SearchResult = {

type Meta = {
results: SearchResult[]
/** Absolute root the index covers, so a caller can tell what was actually searched. */
root: string
/** Index state at query time; only resolved when nothing matched. */
state?: KiloIndexing.Status["state"]
}

export const SemanticSearchTool = Tool.define(
Expand Down Expand Up @@ -53,6 +58,7 @@ export const SemanticSearchTool = Tool.define(
})

const prefix = normalizeSearchPath(params.path)
const root = normalizePath(Instance.directory)
const matches = yield* Effect.promise(() => KiloIndexing.search(params.query, prefix))

const results = matches.flatMap<SearchResult>((item) => {
Expand All @@ -79,17 +85,28 @@ export const SemanticSearchTool = Tool.define(
})

if (results.length === 0) {
// An empty result set is ambiguous: the index may be disabled, still
// building, or broken. Report which, so the caller does not read this
// as proof that no matching code exists.
const status = yield* Effect.promise(() =>
KiloIndexing.current().then(
(value) => value,
() => undefined,
),
)
return {
title: "Codebase Search",
metadata: {
results,
root,
state: status?.state,
},
output: `No relevant code found for "${params.query}"${prefix ? ` in ${normalizePath(prefix)}` : ""}.`,
output: empty(params.query, root, prefix, status),
}
}

const output = [
`Found ${results.length} result${results.length === 1 ? "" : "s"} for "${params.query}"${prefix ? ` in ${normalizePath(prefix)}` : ""}.`,
`Found ${results.length} result${results.length === 1 ? "" : "s"} for "${params.query}" in ${scope(root, prefix)}.`,
"",
...results.flatMap((item, index) => {
return [
Expand All @@ -104,6 +121,7 @@ export const SemanticSearchTool = Tool.define(
title: "Codebase Search",
metadata: {
results,
root,
},
output: output.join("\n").trim(),
}
Expand All @@ -122,7 +140,3 @@ function normalizeSearchPath(input?: string): string | undefined {
}
return path.normalize(relative)
}

function normalizePath(value: string): string {
return value.replaceAll("\\", "/")
}
8 changes: 5 additions & 3 deletions packages/opencode/src/kilocode/tool/semantic-search.txt
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ follow up with Grep and Read. Prefer Grep directly when exact terms are already
- Search for an exact symbol or regex pattern — use `Grep`
- Find files by filename or extension — use `Glob`
- Read the contents of a known file — use `Read`
- Explore files outside the current workspace - use `Grep`, `Glob`, and `Read`
- Explore files outside the indexed root — use `Read` with an absolute path. `Grep` and `Glob` are bounded to the same root as this tool and cannot reach outside it either.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: This states that Grep and Glob "cannot reach outside" the indexed root, but that appears inaccurate. Both tools accept absolute paths and delegate to assertExternalDirectoryEffect (packages/opencode/src/tool/grep.ts:60, packages/opencode/src/tool/glob.ts:65), which allows paths outside the root after an external_directory prompt — and the default action when no rule matches is ask (packages/opencode/src/permission/index.ts:107). glob.ts even has explicit Kilo support for absolute patterns such as ~/.config/kilo/command/*.md. Bounded-by-default is the useful point; "cannot reach outside" is stronger than the implementation. Consider rewording to something like "they search the indexed root by default and need external_directory approval to go outside it" so the model does not avoid a working approach.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — you're right, and I've fixed it in 8b7b8a5.

I had verified this earlier against an older revision where both tools resolved a base and then hard-failed with Path escapes the active Location. That is no longer how they work: glob.ts:65 and grep.ts:60 now call assertExternalDirectoryEffect, which returns false when the target is inside the instance directory or worktree and otherwise raises an external_directory prompt via ctx.ask (external-directory.ts:36-54). It permits the path once approved rather than refusing it, and glob.ts carries an explicit Kilo change for absolute patterns.

So "cannot reach outside" was wrong, and wrong in the direction that matters — it would steer a model away from an approach that works.

Reworded to keep the useful distinction rather than drop it, since semantic_search genuinely cannot go outside its indexed root:

Explore files outside the indexed root — use Read, Grep or Glob with an absolute path. They search the indexed root by default and need external_directory approval to go beyond it, but they can get there; this tool cannot.

I applied the same correction to the constraint line and to the runtime empty-result message, which carried the same "use Read with an absolute path" advice. Tests updated for the new wording and passing.


## Examples

Expand All @@ -23,5 +23,7 @@ follow up with Grep and Read. Prefer Grep directly when exact terms are already
## Constraints

- Write the query in English.
- Searches the entire current workspace by default. Limit semantic search to one subdirectory with `path`.
- Cannot search outside the current workspace. Use other tools if this functionality is needed.
- Searches one indexed root: the project directory of the current session. Limit the search to a subdirectory of that root with `path`.
- In a multi-root editor workspace, only that one root is indexed. Files in other workspace folders are not searchable here even though they may appear as `@` mentions.
- Cannot search outside the indexed root. Use `Read` with an absolute path instead.
- Empty results are not proof that no matching code exists. The output states whether the index was complete, still building, disabled, or failed; read it before concluding anything.
31 changes: 28 additions & 3 deletions packages/opencode/test/kilocode/semantic-search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,17 @@ import { Truncate } from "../../src/tool/truncate"

const rt = ManagedRuntime.make(Layer.mergeAll(AppNodeBuilder.build(Truncate.node), AppNodeBuilder.build(Agent.node)))

const slash = (value: string) => value.replaceAll("\\", "/")

/** A finished index, so empty-result wording does not depend on real indexing progress. */
const complete = {
state: "Complete",
message: "Index up-to-date.",
processedFiles: 0,
totalFiles: 0,
percent: 100,
} satisfies KiloIndexing.Status

async function initTool() {
return rt.runPromise(
Effect.gen(function* () {
Expand Down Expand Up @@ -54,6 +65,7 @@ describe("tool.semantic_search", () => {
fn: async () => {
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const search = spyOn(KiloIndexing, "search").mockResolvedValue([])
const status = spyOn(KiloIndexing, "current").mockResolvedValue(complete)

try {
const tool = await initTool()
Expand All @@ -80,9 +92,15 @@ describe("tool.semantic_search", () => {
path: "./src/../src/tool",
})
expect(search).toHaveBeenCalledWith("authentication middleware", path.normalize("src/tool"))
expect(result.output).toBe('No relevant code found for "authentication middleware" in src/tool.')
// The searched scope is named in full, so a caller can tell what an
// empty result actually covered.
expect(result.output.split("\n")[0]).toBe(
`No results for "authentication middleware" in ${slash(tmp.path)}/src/tool.`,
)
expect(result.metadata.root).toBe(slash(tmp.path))
} finally {
search.mockRestore()
status.mockRestore()
}
},
})
Expand All @@ -94,16 +112,23 @@ describe("tool.semantic_search", () => {
directory: tmp.path,
fn: async () => {
const search = spyOn(KiloIndexing, "search").mockResolvedValue([])
const status = spyOn(KiloIndexing, "current").mockResolvedValue(complete)

try {
const tool = await initTool()
const result = await rt.runPromise(tool.execute({ query: "database connection" }, baseCtx))

expect(search).toHaveBeenCalledWith("database connection", undefined)
expect(result.output).toBe('No relevant code found for "database connection".')
expect(result.output.split("\n")).toEqual([
`No results for "database connection" in ${slash(tmp.path)}.`,
"The index is up to date, so no semantically similar code exists in this scope.",
`Only ${slash(tmp.path)} is indexed. Files in other workspace folders are not searchable here — use Read with an absolute path.`,
])
expect(result.metadata.results).toEqual([])
expect(result.metadata.state).toBe("Complete")
} finally {
search.mockRestore()
status.mockRestore()
}
},
})
Expand Down Expand Up @@ -155,7 +180,7 @@ describe("tool.semantic_search", () => {
codeChunk: "export const verify = () => true",
},
])
expect(result.output).toContain('Found 1 result for "verify token".')
expect(result.output).toContain(`Found 1 result for "verify token" in ${slash(tmp.path)}.`)
expect(result.output).toContain("1. src/auth/index.ts:10-18 (score 0.8123)")
expect(result.output).toContain("export const verify = () => true")
} finally {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { describe, expect, test } from "bun:test"
import type { IndexingStatus } from "@kilocode/kilo-indexing/status"
import { empty, reason, scope } from "@/kilocode/tool/semantic-search-output"

function status(over: Partial<IndexingStatus>): IndexingStatus {
return { state: "Complete", message: "", processedFiles: 0, totalFiles: 0, percent: 0, ...over }
}

describe("scope", () => {
test("names the root when the whole index was searched", () => {
expect(scope("/repo")).toBe("/repo")
})

test("names the subdirectory when the search was narrowed", () => {
expect(scope("/repo", "src/app")).toBe("/repo/src/app")
})

test("reports a Windows prefix with forward slashes", () => {
expect(scope("C:/repo", "src\\app")).toBe("C:/repo/src/app")
})
})

describe("reason", () => {
test("distinguishes a complete index from an unavailable one", () => {
expect(reason(status({ state: "Complete" }))).toContain("up to date")
expect(reason(undefined)).toContain("not evidence")
})

test("reports progress while the index is still building", () => {
const text = reason(status({ state: "In Progress", percent: 40, processedFiles: 120, totalFiles: 300 }))
expect(text).toContain("still building (40%, 120/300 files)")
expect(text).toContain("incomplete")
})

test("says nothing was searched when indexing is disabled", () => {
const text = reason(status({ state: "Disabled", message: "Enable it in Kilo Settings." }))
expect(text).toContain("disabled for this project")
expect(text).toContain("nothing was searched")
expect(text).toContain("Enable it in Kilo Settings.")
})

test("says nothing was searched when indexing failed", () => {
expect(reason(status({ state: "Error", message: "Failed to initialize: bad model" }))).toContain(
"Codebase indexing failed, so nothing was searched. Failed to initialize: bad model",
)
})

test("omits an empty status message rather than leaving trailing space", () => {
expect(reason(status({ state: "Disabled", message: " " }))).toBe(
"Codebase indexing is disabled for this project, so nothing was searched.",
)
})
})

describe("empty", () => {
test("states the query, the searched scope, the index state, and the multi-root caveat", () => {
const text = empty("auth flow", "/repo", undefined, status({ state: "Complete" }))
expect(text.split("\n")).toEqual([
'No results for "auth flow" in /repo.',
"The index is up to date, so no semantically similar code exists in this scope.",
"Only /repo is indexed. Files in other workspace folders are not searchable here — use Read with an absolute path.",
])
})

test("does not claim the code is absent when the index never ran", () => {
const text = empty("auth flow", "/repo", undefined, status({ state: "Disabled" }))
expect(text).not.toContain("no semantically similar code exists")
expect(text).toContain("nothing was searched")
})
})
Loading