diff --git a/docs/docs/configure/config.md b/docs/docs/configure/config.md index 737d0e03e..e6d58bb78 100644 --- a/docs/docs/configure/config.md +++ b/docs/docs/configure/config.md @@ -73,6 +73,8 @@ These flags are under `experimental` and may change or be removed in future rele | Flag | Type | Default | Description | |------|------|---------|-------------| | `env_fingerprint_skill_selection` | `boolean` | `false` | Use environment fingerprint (dbt, airflow, databricks, SQL) to select relevant skills once per session via LLM. When enabled, the configured model runs once at session start to filter skills based on detected project environment. Results are cached per working directory. When disabled (default), all skills are shown. | +| `bounded_skill_listing` | `boolean` | `true` | Keep the skill listing in the system prompt and in the `skill` tool inside a fixed token budget however many skills are installed. Skills that do not fit with a description are listed by name; past that, they are not listed, and the `skill` tool finds them when it is given a keyword instead of an exact name (it searches the skills the current agent may use). Set to `false` for the previous listing (full in the system prompt, at most 50 skills in the `skill` tool). Environment override: `ALTIMATE_BOUNDED_SKILL_LISTING=0` or `1`. | +| `smaller_tool_list` | `boolean` | `false` | Send a smaller tool list with each request. Data-engineering tools that do not fit the project (for example warehouse cost analysis when no warehouse is configured) are not sent; the model reaches them through a fixed `tool_run` tool, which lists their names. Which tools are sent is decided once when the session starts, from the project (a dbt project, SQL files, a configured warehouse, saved memory), and never changes during the session, so the prompt cache is not disturbed. Tools you add yourself (plugins, MCP servers, custom tools) are always sent. Environment override: `ALTIMATE_SMALLER_TOOL_LIST=1` or `0`. | | `auto_enhance_prompt` | `boolean` | `false` | Automatically rewrite prompts with AI before sending. Uses a small model to clarify rough prompts. | Example: diff --git a/packages/core/src/v1/config/config.ts b/packages/core/src/v1/config/config.ts index b6fe79e97..c8b1c34ae 100644 --- a/packages/core/src/v1/config/config.ts +++ b/packages/core/src/v1/config/config.ts @@ -375,6 +375,14 @@ export const Info = Schema.Struct({ description: "Use environment fingerprint to select relevant skills once per session (default: false). Set to true to enable LLM-based skill filtering.", }), + bounded_skill_listing: Schema.optional(Schema.Boolean).annotate({ + description: + "Keep the skill listing in the system prompt and the skill tool inside a fixed token budget, however many skills are installed (default: true). Skills that do not fit are listed by name, then not listed at all; every skill can be found by passing a keyword as the skill name. Set to false for the previous listing (full in the system prompt, at most 50 skills in the skill tool).", + }), + smaller_tool_list: Schema.optional(Schema.Boolean).annotate({ + description: + "Offer a smaller default tool list: data-engineering tools that do not fit the project (for example warehouse cost analysis in a project with no warehouse) are reached through the `tool_run` tool instead of being sent with every request. The list is decided once per session, so the prompt cache is not disturbed (default: false).", + }), auto_mcp_discovery: Schema.optional(Schema.Boolean).annotate({ description: "Auto-discover MCP servers from VS Code, Claude Code, Copilot, and Gemini configs at startup (default: true). Set to false to disable.", diff --git a/packages/opencode/src/altimate/skill-listing.ts b/packages/opencode/src/altimate/skill-listing.ts new file mode 100644 index 000000000..2f7be8449 --- /dev/null +++ b/packages/opencode/src/altimate/skill-listing.ts @@ -0,0 +1,214 @@ +/** + * Bounded skill listing. + * + * The `skill` tool description and the system prompt both enumerate the installed skills. Without a + * bound, the system-prompt copy grows with every skill (about 190 tokens each: name, description and a + * file URL) and the tool-description copy stops at 50 entries, leaving skill 51 onward unreachable by + * the model unless it already knows the name. Both sit in the prompt prefix that is re-sent on every + * request. + * + * This renders a listing that stays inside a token budget and never hides a skill: + * 1. Skills are ordered deterministically (embedded skills first, then by name), so the text is the + * same for the same set of skills and the cached prefix is stable. + * 2. Every skill gets at least its name. Descriptions (single line, truncated) are added in order + * while the budget lasts. + * 3. If even the names do not fit, the tail is replaced by a count and a pointer to the search below. + * 4. Passing a keyword as the skill `name` searches all installed skills (see `findSkills`), so a + * skill that is not shown can still be found, then loaded by its exact name. + * + * Budgets are in estimated tokens (4 characters each); the estimate is only used to bound size. + */ +import { Skill } from "../skill" + +export const TOOL_LISTING_BUDGET_TOKENS = 1_000 +export const SYSTEM_LISTING_BUDGET_TOKENS = 2_500 +/** + * Description lengths to try, longest first. The listing uses the longest one at which every skill + * still fits its budget, so a short list keeps its trigger conditions and a long one degrades evenly. + */ +const TOOL_DESCRIPTION_CAPS = [160, 70] +const SYSTEM_DESCRIPTION_CAPS = [400, 160] +const SYSTEM_DESCRIPTION_CHARS = 160 +const CHARS_PER_TOKEN = 4 +const MATCH_LIMIT = 15 +const NAME_LIMIT = 40 +/** A name longer than this is shortened for display; it is still found by search and loaded by its full name. */ +const DISPLAY_NAME_CHARS = 100 +/** The longest name a search result shows whole. */ +const RESULT_NAME_CHARS = 256 +/** Room kept for the wrapper tags and the "not listed" footer, which the entries must not crowd out. */ +const OVERHEAD_CHARS = 400 + +export type ListingKind = "tool" | "system" + +type Entry = Pick + +const compare = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) + +/** Embedded (shipped) skills first, then everything else; each group by name. */ +export function orderSkills(skills: readonly T[]): T[] { + const rank = (skill: Entry) => (Skill.hasNoSkillDirectory(skill.location) ? 0 : 1) + return [...skills].sort((a, b) => rank(a) - rank(b) || compare(a.name, b.name) || compare(a.location, b.location)) +} + +/** Collapse to one line and cut to `max` characters by code point, so a surrogate pair is never split. */ +function oneLine(text: string, max: number): string { + const flat = text.replace(/\s+/g, " ").trim() + const chars = Array.from(flat) + return chars.length <= max ? flat : `${chars.slice(0, max - 1).join("").trimEnd()}…` +} + +/** + * How a name is shown. A plain name is shown as it is, so it can be copied back. A name with line breaks, + * repeated spaces or tabs is shown JSON-quoted, which keeps it exact on one line. A name over the display + * length is shortened with an ellipsis; search shows the longer form. + */ +function display(name: string, max: number): string { + const neutral = Skill.neutralizeListingWrapper(name) + const plain = neutral === neutral.replace(/\s+/g, " ").trim() + // JSON leaves U+2028 and U+2029 as they are; they would still break a line, so escape them too. + const shown = plain ? neutral : JSON.stringify(neutral).replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029") + const chars = Array.from(shown) + return chars.length <= max ? shown : `${chars.slice(0, max - 1).join("")}…` +} + +function label(skill: Entry) { + return display(skill.name, DISPLAY_NAME_CHARS) +} + +function line(skill: Entry, max: number, name = label(skill)): string { + const description = oneLine(Skill.neutralizeListingWrapper(skill.description ?? ""), max) + return description ? `${name}: ${description}` : name +} + +export function renderBoundedListing(skills: readonly Entry[], kind: ListingKind): string { + const ordered = orderSkills(skills) + const budget = + (kind === "tool" ? TOOL_LISTING_BUDGET_TOKENS : SYSTEM_LISTING_BUDGET_TOKENS) * CHARS_PER_TOKEN - OVERHEAD_CHARS + const caps = kind === "tool" ? TOOL_DESCRIPTION_CAPS : SYSTEM_DESCRIPTION_CAPS + const names = ordered.map(label) + const render = (max: number) => ordered.map((skill) => line(skill, max)) + // Same per-entry cost as the spending loop below: the line, a separator and a newline. + const total = (lines: string[]) => lines.reduce((sum, l) => sum + l.length + 2, 0) + let full = render(caps[caps.length - 1]!) + for (const max of caps) { + const lines = render(max) + if (total(lines) <= budget) { + full = lines + break + } + } + + // Names are the floor: show as many as fit, in order. + let used = 0 + let listed = 0 + for (const name of names) { + const next = used + name.length + 2 + if (next > budget) break + used = next + listed++ + } + + // Spend what is left on descriptions, front to back, and stop at the first one that does not fit. + let described = 0 + while (described < listed) { + const extra = full[described]!.length - names[described]!.length + if (used + extra > budget) break + used += extra + described++ + } + + const hidden = ordered.length - listed + const out = [""] + for (let i = 0; i < described; i++) out.push(full[i]!) + if (listed > described) out.push(names.slice(described, listed).join(", ")) + if (hidden > 0) { + out.push( + `... and ${hidden} more installed skills (${ordered.length} in total) are not listed here. ` + + `Call this tool with a keyword as the name (for example "snowflake") to search all of them.`, + ) + } else if (ordered.length > 0 && listed > described) { + out.push(`Call this tool with a keyword as the name to search skills by description.`) + } + out.push("") + return out.join("\n") +} + +function words(text: string): string[] { + return text.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? [] +} + +/** + * Rank every installed skill against a query. Deterministic: score, then the listing order. + * Used when the model passes something that is not an exact skill name. + */ +export function findSkills(skills: readonly T[], query: string, limit = MATCH_LIMIT): T[] { + const terms = [...new Set(words(query))] + if (terms.length === 0) return [] + const exact = query.trim().toLowerCase() + return orderSkills(skills) + .map((skill, index) => { + const name = skill.name.toLowerCase() + const description = (skill.description ?? "").toLowerCase() + // A skill is always found by its own name: an exact match outranks any keyword score. + let score = name === exact ? 1_000_000 : 0 + let covered = 0 + for (const term of terms) { + const inName = name.includes(term) + const inDescription = description.includes(term) + if (inName) score += 3 + if (inDescription) score += 1 + if (inName || inDescription) covered++ + } + // Matching more of the query beats matching one term strongly. + score += covered * 10_000 + return { skill, score, index } + }) + .filter((hit) => hit.score > 0) + .sort((a, b) => b.score - a.score || a.index - b.index) + .slice(0, limit) + .map((hit) => hit.skill) +} + +/** The text of a failed lookup: matches with descriptions when the keyword hits, else a bounded name list. */ +export function notFoundMessage(skills: readonly Entry[], requested: string): string { + const matches = findSkills(skills, requested) + const head = `Skill "${oneLine(Skill.neutralizeListingWrapper(requested), 80)}" not found. ${skills.length} skills are installed.` + if (matches.length > 0) { + const total = findSkills(skills, requested, Number.MAX_SAFE_INTEGER).length + return [ + head, + `Closest matches (call this tool again with the exact name to load one):`, + // Search results carry the full name: it is what the skill is loaded by. + ...matches.map((skill) => `- ${line(skill, SYSTEM_DESCRIPTION_CHARS, display(skill.name, RESULT_NAME_CHARS))}`), + ...(total > matches.length ? [`... ${total - matches.length} more match; use a more specific keyword.`] : []), + ].join("\n") + } + const ordered = orderSkills(skills) + const shown = ordered.slice(0, NAME_LIMIT).map(label) + const rest = ordered.length - shown.length + return [ + head, + `No skill matches that keyword. ${rest > 0 ? "First" : "Available"} skills: ${shown.join(", ") || "none"}${rest > 0 ? `, and ${rest} more` : ""}.`, + ].join("\n") +} + +/** + * Configuration switch. The environment variable wins over the config file; `fallback` applies when + * neither is set. + */ +export function switchEnabled(envKey: string, configured: boolean | undefined, fallback: boolean): boolean { + const raw = process.env[envKey]?.toLowerCase() + if (raw === "1" || raw === "true") return true + if (raw === "0" || raw === "false") return false + return configured ?? fallback +} + +/** Default for the bounded listing. */ +export const BOUNDED_SKILL_LISTING_DEFAULT = true + +export function boundedSkillListingEnabled(configured: boolean | undefined): boolean { + return switchEnabled("ALTIMATE_BOUNDED_SKILL_LISTING", configured, BOUNDED_SKILL_LISTING_DEFAULT) +} + +export * as SkillListing from "./skill-listing" diff --git a/packages/opencode/src/altimate/tool-run.ts b/packages/opencode/src/altimate/tool-run.ts new file mode 100644 index 000000000..0c1413126 --- /dev/null +++ b/packages/opencode/src/altimate/tool-run.ts @@ -0,0 +1,86 @@ +/** + * `tool_run`: the one fixed tool through which a tool outside the offered list is called. + * + * Its definition (name, schema, and the index of reachable tools) is fixed for the whole session, so + * reaching a hidden tool never changes the tool block of the prompt (see tool-selection.ts). + */ +import { tool, jsonSchema, asSchema, type Tool as AITool } from "ai" +import { Log } from "@/util/log" +import { ToolLookup } from "./tools/tool-lookup" +import { ToolSelection } from "./tool-selection" + +const log = Log.create({ service: "tool.run" }) + +/** Calls the model addressed to a hidden tool directly, which were rerouted here instead of failing. */ +const rerouted = new Set() +export function markRerouted(toolCallId: string) { + // A rerouted call that never executes (an abort) would leave its id behind; keep the set small. + if (rerouted.size > 1000) rerouted.clear() + rerouted.add(toolCallId) +} + +function contract(target: AITool): string { + try { + const schema = (asSchema(target.inputSchema) as { jsonSchema: any }).jsonSchema + const params = ToolLookup.describeJsonSchema(schema) + if (params.length === 0) return "No parameters." + return params + .map((p) => ` ${p.name} (${p.type}, ${p.required ? "required" : "optional"})${p.description ? ` ${p.description}` : ""}`) + .join("\n") + } catch { + return "Use tool_lookup to see the parameters." + } +} + +export function createRunTool(input: { + hidden: Record +}): AITool { + const names = Object.keys(input.hidden) + const run = tool({ + description: ToolSelection.runDescription(names), + inputSchema: jsonSchema<{ name?: unknown; arguments?: unknown }>({ + type: "object", + properties: { + name: { type: "string", description: "Exact name of the tool to run" }, + arguments: { type: "object", description: "The tool's own parameters", additionalProperties: true }, + }, + required: ["name"], + }), + async execute(args: { name?: unknown; arguments?: unknown }, options) { + const name = typeof args.name === "string" ? args.name.trim() : "" + const params: unknown = args.arguments === undefined ? {} : args.arguments + if (typeof params !== "object" || params === null || Array.isArray(params)) { + throw new Error(`tool_run: "arguments" must be an object holding the tool's parameters.`) + } + const target = Object.hasOwn(input.hidden, name) ? input.hidden[name] : undefined + if (!target?.execute) { + throw new Error( + name === "" + ? `tool_run needs a tool name. Available through tool_run: ${names.join(", ")}` + : `No tool named "${name}" is available through tool_run. Available: ${names.join(", ")}. ` + + `Tools in your own tool list are called directly.`, + ) + } + // The permission deny for a governed tool is enforced inside the target's own wrapper (session/prompt.ts), + // after the call's execution has been registered, so a refusal pairs with its call even when a provider + // repeats call ids. + const wasRerouted = options.toolCallId ? rerouted.delete(options.toolCallId) : false + log.info("run", { tool: name, rerouted: wasRerouted }) + try { + const result = (await target.execute(params as never, options)) as { metadata?: Record } + if (result && typeof result === "object") { + return { ...result, metadata: { ...result.metadata, tool_run: name, ...(wasRerouted ? { rerouted: true } : {}) } } + } + return result + } catch (e) { + const message = e instanceof Error ? e.message : String(e) + if (/invalid arguments/i.test(message)) throw new Error(`${message}\n\nParameters of ${name}:\n${contract(target)}`) + throw e + } + }, + }) + ToolSelection.attachHidden(run, input.hidden) + return run +} + +export * as ToolRun from "./tool-run" diff --git a/packages/opencode/src/altimate/tool-selection.ts b/packages/opencode/src/altimate/tool-selection.ts new file mode 100644 index 000000000..19ec6103d --- /dev/null +++ b/packages/opencode/src/altimate/tool-selection.ts @@ -0,0 +1,470 @@ +/** + * Smaller default tool list, with a way to reach the rest. + * + * Every request carries the definitions of all built-in tools (about 20k tokens), and a session calls a + * handful. This module decides, once per session and without a model, which of the data-engineering tools + * are offered directly. The rest stay reachable through one fixed `tool_run` tool. + * + * Prompt-cache contract (the reason for this shape): + * - Tool definitions are the first block of the cached prompt prefix, ahead of the system prompt and the + * messages. Any change to them re-writes the cache for the whole conversation. + * - The offered list is a pure function of facts read once at session start (a dbt project, SQL files, a + * configured warehouse) and is remembered per session. It is identical on every step of the session, + * and identical across sessions that start from the same project state, so cross-session cache hits + * are kept for those. + * - A tool outside the list is never added to it. The model calls it through `tool_run`, whose + * definition does not change, so using it costs zero cache invalidations. (Adding the tool to the + * list at the next step would re-write the whole conversation once, at the cache-write price.) + * - A tool id this module does not know is always offered. A new tool therefore cannot disappear. + */ +import path from "path" +import { Filesystem } from "../util/filesystem" +import { SkillListing } from "./skill-listing" +import { Wildcard } from "../util/wildcard" +import type { Tool as AITool } from "ai" + +export const TOOL_RUN = "tool_run" + +/** Off until a live run shows no loss in task success; see the pull request. */ +export const SMALLER_TOOL_LIST_DEFAULT = false + +export function smallerToolListEnabled(configured: boolean | undefined): boolean { + return SkillListing.switchEnabled("ALTIMATE_SMALLER_TOOL_LIST", configured, SMALLER_TOOL_LIST_DEFAULT) +} + +export type Group = + | "environment" + | "warehouse_query" + | "dbt" + | "sql_quality" + | "sql_compare" + | "lineage" + | "finops" + | "access" + | "governance" + | "schema_utils" + | "product_admin" + | "memory" + +/** Optional tools by group. A tool in no group is always offered. */ +export const GROUPS: Record = { + environment: { + label: "project and warehouse connections", + tools: [ + "project_scan", + "warehouse_list", + "warehouse_test", + "warehouse_add", + "warehouse_install_driver", + "warehouse_remove", + "warehouse_discover", + ], + }, + warehouse_query: { + label: "run SQL on a live warehouse, inspect and search its schema", + tools: [ + "sql_execute", + "sql_explain", + "schema_inspect", + "schema_index", + "schema_search", + "schema_cache_status", + "sql_autocomplete", + "data_diff", + ], + }, + dbt: { + label: "dbt project analysis", + tools: [ + "dbt_manifest", + "dbt_unit_test_gen", + "dbt_profiles", + "dbt_lineage", + "altimate_core_parse_dbt", + "impact_analysis", + "dbt_pr_review", + ], + }, + sql_quality: { + label: "static SQL analysis, validation and fixing", + tools: [ + "sql_analyze", + "sql_optimize", + "sql_format", + "sql_fix", + "sql_rewrite", + "altimate_core_rewrite", + "altimate_core_validate", + "altimate_core_check", + "altimate_core_fix", + "altimate_core_semantics", + "altimate_core_testgen", + "altimate_core_correct", + "altimate_core_grade", + "altimate_core_complete", + "altimate_core_extract_metadata", + ], + }, + sql_compare: { + label: "compare or translate SQL and schemas", + tools: [ + "sql_translate", + "sql_diff", + "schema_diff", + "altimate_core_equivalence", + "altimate_core_migration", + "altimate_core_schema_diff", + "altimate_core_compare", + ], + }, + lineage: { + label: "column-level lineage", + tools: ["lineage_check", "altimate_core_column_lineage", "altimate_core_track_lineage"], + }, + finops: { + label: "warehouse cost and usage", + tools: [ + "finops_query_history", + "finops_analyze_credits", + "finops_expensive_queries", + "finops_warehouse_advice", + "finops_unused_resources", + ], + }, + access: { + label: "warehouse roles and permissions", + tools: ["finops_role_grants", "finops_role_hierarchy", "finops_user_roles"], + }, + governance: { + label: "PII detection, tags and policy", + tools: [ + "schema_detect_pii", + "schema_tags", + "schema_tags_list", + "altimate_core_classify_pii", + "altimate_core_query_pii", + "altimate_core_policy", + ], + }, + schema_utils: { + label: "schema file utilities", + tools: [ + "altimate_core_optimize_context", + "altimate_core_prune_schema", + "altimate_core_import_ddl", + "altimate_core_export_ddl", + "altimate_core_fingerprint", + "altimate_core_introspection_sql", + "altimate_core_resolve_term", + ], + }, + product_admin: { + label: "product setup", + tools: ["datamate_manager", "mcp_discover", "feedback_submit", "sample_setup"], + }, + memory: { + label: "saved memory notes", + tools: [ + "altimate_memory_read", + "altimate_memory_write", + "altimate_memory_delete", + "altimate_memory_audit", + "altimate_memory_refresh", + "altimate_memory_extract", + ], + }, +} + +/** + * Optional tools that the default (builder) instructions direct the model to call by name. They are + * offered whatever the project looks like, because the instructions would otherwise point at a tool that + * is not in the list. A test keeps this in step with the instruction packs. + */ +export const NAMED_BY_PROMPT = [ + "sql_execute", + "schema_inspect", + "sql_analyze", + "lineage_check", + "warehouse_list", + "warehouse_test", + "dbt_lineage", + "schema_search", + "sql_fix", + "altimate_core_validate", + "altimate_core_fix", + // named by the shipped setup and feedback commands + "project_scan", + "warehouse_add", + "feedback_submit", + "mcp_discover", + // named by the workspace identity section of the system prompt + "altimate_memory_read", + "altimate_memory_write", + // named by the skills catalogue in the default instructions + "sql_optimize", + "sql_explain", + "dbt_unit_test_gen", + "data_diff", + "altimate_core_check", + "altimate_core_grade", + "altimate_core_equivalence", +] + +export interface Facts { + dbtProject: boolean + sqlFiles: boolean + warehouse: boolean + /** The memory store already holds entries. */ + memory: boolean +} + +const DBT_GROUPS: Group[] = ["environment", "dbt", "warehouse_query", "sql_quality", "lineage"] +const SQL_GROUPS: Group[] = ["sql_quality", "sql_compare", "lineage"] +const WAREHOUSE_GROUPS: Group[] = [ + "environment", + "warehouse_query", + "sql_quality", + "sql_compare", + "lineage", + "finops", + "access", + "governance", + "schema_utils", +] +const NO_FACT_GROUPS: Group[] = ["environment", "product_admin"] + +export function groupsFor(facts: Facts): Set { + const on = new Set() + if (facts.memory) on.add("memory") + if (facts.dbtProject) for (const g of DBT_GROUPS) on.add(g) + if (facts.sqlFiles) for (const g of SQL_GROUPS) on.add(g) + if (facts.warehouse) for (const g of WAREHOUSE_GROUPS) on.add(g) + // No project signal: let the agent discover and set things up. + if (!facts.dbtProject && !facts.sqlFiles && !facts.warehouse) for (const g of NO_FACT_GROUPS) on.add(g) + return on +} + +const GROUP_OF = new Map( + (Object.entries(GROUPS) as [Group, { tools: string[] }][]).flatMap(([group, def]) => + def.tools.map((id) => [id, group] as const), + ), +) +const NAMED = new Set(NAMED_BY_PROMPT) + +/** Whether an agent's own instructions name the tool: then it is offered, whatever the project looks like. */ +function namesTool(prompt: string, id: string): boolean { + return prompt.length > 0 && new RegExp(`(^|[^A-Za-z0-9_])${id}($|[^A-Za-z0-9_])`).test(prompt) +} + +/** Tool ids (of the native ones given) that are not offered directly under these groups. */ +export function hiddenIds(nativeIds: Iterable, on: ReadonlySet, agentPrompt = ""): string[] { + const hidden: string[] = [] + for (const id of nativeIds) { + const group = GROUP_OF.get(id) + if (!group || on.has(group) || NAMED.has(id) || namesTool(agentPrompt, id)) continue + hidden.push(id) + } + return hidden +} + +/** Read the session-start facts. No model and no network; every check is a cheap local probe. */ +export async function detectFacts(input: { + directory: string + /** The project boundary (the worktree): a dbt project above the session directory is found up to here. */ + root?: string + /** The directory that holds the user-level `.altimate-code/connections.json`. */ + home: string + memoryPresent: () => Promise +}): Promise { + const dir = input.directory + const [signals, above, memory, warehouse] = await Promise.all([ + scanProject(dir), + dbtProjectAbove(dir, input.root), + input.memoryPresent().catch(() => false), + warehouseDeclared(dir, input.home), + ]) + return { dbtProject: signals.dbtProject || above, sqlFiles: signals.sqlFiles, warehouse, memory } +} + +/** A `dbt_project.yml` in a parent of the session directory, up to and including the project boundary. */ +async function dbtProjectAbove(dir: string, root?: string): Promise { + if (!root) return false + // Windows paths differ in case for the same folder. + const fold = (p: string) => (process.platform === "win32" ? p.toLowerCase() : p) + const boundary = fold(path.resolve(root)) + // No Git project: the worktree is the filesystem root, which is not a project boundary. + if (boundary === path.dirname(boundary)) return false + const inside = (candidate: string) => fold(candidate) === boundary || fold(candidate).startsWith(boundary + path.sep) + let current = path.dirname(path.resolve(dir)) + while (inside(current) && current !== path.dirname(current)) { + if (await Filesystem.exists(path.join(current, "dbt_project.yml"))) return true + if (fold(current) === boundary) break + current = path.dirname(current) + } + return false +} + +const SKIP_DIRS = new Set(["node_modules", "dbt_packages", "target", "venv", "site-packages", "dist", "build"]) +const MAX_DEPTH = 3 +const MAX_DIRS = 400 +const MAX_ENTRIES_PER_DIR = 500 + +/** + * Look for a dbt project and SQL files in the project directory and up to three levels below it, so a + * monorepo with dbt in a subfolder is recognised. Bounded (directories, entries per directory and depth), skips dot-directories and + * dependency or build folders, and reads names only. + */ +async function scanProject(root: string): Promise<{ dbtProject: boolean; sqlFiles: boolean }> { + const { readdir } = await import("fs/promises") + let dbtProject = false + let sqlFiles = false + let dirs = 0 + let level = [root] + for (let depth = 0; depth <= MAX_DEPTH && level.length > 0; depth++) { + const next: string[] = [] + for (const dir of level) { + if (++dirs > MAX_DIRS) return { dbtProject, sqlFiles } + let entries + try { + entries = await readdir(dir, { withFileTypes: true }) + } catch { + continue + } + // Marker files are checked in every entry; the cap applies to the folders walked into, so a large + // folder neither hides a marker next to it nor crowds out its siblings. + const folders: string[] = [] + for (const entry of entries) { + if (entry.isFile()) { + if (entry.name === "dbt_project.yml") dbtProject = true + else if (entry.name.toLowerCase().endsWith(".sql")) sqlFiles = true + } else if (entry.isDirectory() && !entry.name.startsWith(".") && !SKIP_DIRS.has(entry.name)) { + folders.push(entry.name) + } + } + for (const name of folders.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)).slice(0, MAX_ENTRIES_PER_DIR)) { + next.push(path.join(dir, name)) + } + if (dbtProject && sqlFiles) return { dbtProject, sqlFiles } + } + level = next + } + return { dbtProject, sqlFiles } +} + +/** + * Whether any warehouse connection is declared for this project: the project's or the user's + * connections file has an entry, or a connection environment variable is set. Read from the files + * directly, not from the connection registry, which is process-wide and keeps the first project's + * connections loaded for every later one. + */ +async function warehouseDeclared(dir: string, home: string): Promise { + // The same acceptance as the connection registry: a config object that has a `type`. + const usable = (value: unknown) => !!value && typeof value === "object" && typeof (value as { type?: unknown }).type === "string" + for (const [key, value] of Object.entries(process.env)) { + if (!key.startsWith("ALTIMATE_CODE_CONN_") || !value) continue + try { + if (usable(JSON.parse(value))) return true + } catch { + // not JSON: the registry ignores it too + } + } + const { readFile } = await import("fs/promises") + for (const file of [path.join(dir, ".altimate-code", "connections.json"), path.join(home, ".altimate-code", "connections.json")]) { + try { + const parsed = JSON.parse(await readFile(file, "utf-8")) + if (parsed && typeof parsed === "object" && Object.values(parsed).some(usable)) return true + } catch { + // absent or unreadable: not declared + } + } + return false +} + +/** Decided once per session in this process. The decision is the set of groups; it is not recomputed. */ +const decided = new Map>>() + +export function decide(sessionID: string, facts: () => Promise): Promise> { + // The promise is stored before it settles, so concurrent callers share one decision. + const known = decided.get(sessionID) + if (known) return known + const pending = facts().then(groupsFor) + decided.set(sessionID, pending) + pending.catch(() => decided.delete(sessionID)) + return pending +} + +/** + * Whether the rules leave the generated router callable. A blanket `*` deny does not count against it + * (the router carries only the targets that were checked one by one); a rule that names `tool_run`, or a + * wildcard that reaches it more specifically (`tool_*`), does. + */ +export function routerAllowed(rules: readonly { permission: string; pattern: string; action: string }[]): boolean { + // The last matching rule decides, as in `PermissionNext.disabled`. One exception: when that rule is the + // catch-all `*` deny, a specific deny before it still counts, so a blanket deny cannot hide an explicit one. + // Only rules that apply to a whole-tool check count: a rule on a narrower resource pattern (`*: safe`) is + // about something else and cannot lift a deny, exactly as `PermissionNext.evaluate(name, "*")` sees it. + const matching = [...rules].reverse().filter((rule) => Wildcard.match(TOOL_RUN, rule.permission) && Wildcard.match("*", rule.pattern)) + const last = matching[0] + if (!last) return true + if (last.permission !== "*") return last.action !== "deny" || last.pattern !== "*" + if (last.action !== "deny" || last.pattern !== "*") return true + const specific = matching.find((rule) => rule.permission !== "*") + return !specific || specific.action !== "deny" || specific.pattern !== "*" +} + +/** Whether an id is one the smaller-list rule can hide or offer (as opposed to a core or unknown tool). */ +export function isOptional(id: string): boolean { + return GROUP_OF.has(id) +} + +/** + * Whether the rules deny a whole-tool use of `id`: the last rule that matches the tool and applies to + * every resource (`PermissionNext.evaluate(id, "*")`) is a deny. Checked when the tool runs, so the router + * and the direct list cannot run what the rules deny, whatever the list-building step predicted. + */ +export function deniedForAll(id: string, rules: readonly { permission: string; pattern: string; action: string }[]): boolean { + const rule = [...rules].reverse().find((r) => Wildcard.match(id, r.permission) && Wildcard.match("*", r.pattern)) + return rule?.action === "deny" +} + +/** Drop a session's decision when the session is deleted. A resumed session keeps its decision. */ +export function forget(sessionID: string) { + decided.delete(sessionID) +} + +/** Test helper. */ +export function reset(sessionID?: string) { + if (sessionID) decided.delete(sessionID) + else decided.clear() +} + +/** The tools behind `tool_run` for a given `tool_run` object, so a mis-addressed call can be rerouted. */ +const hiddenByRunTool = new WeakMap>() +export function attachHidden(runTool: object, hidden: Record) { + hiddenByRunTool.set(runTool, hidden) +} +export function hiddenFor(runTool: object | undefined): Record | undefined { + return runTool ? hiddenByRunTool.get(runTool) : undefined +} + +/** One line per group: the group's purpose and the tool ids in it. Stable for a given set. */ +export function renderIndex(hidden: readonly string[]): string { + const set = new Set(hidden) + const lines: string[] = [] + for (const def of Object.values(GROUPS)) { + const ids = def.tools.filter((id) => set.has(id)) + if (ids.length > 0) lines.push(`- ${def.label}: ${ids.join(", ")}`) + } + return lines.join("\n") +} + +export function runDescription(hidden: readonly string[]): string { + return [ + "Run a tool that is not in your tool list. Pass its exact name and its own parameters as `arguments`.", + "These tools are available this way (use tool_lookup with a name to see its parameters):", + renderIndex(hidden), + "A tool listed in your tool list is called directly, not through this tool.", + ].join("\n") +} + +export * as ToolSelection from "./tool-selection" diff --git a/packages/opencode/src/altimate/tools/tool-lookup.ts b/packages/opencode/src/altimate/tools/tool-lookup.ts index d2ebd1ec8..93601af34 100644 --- a/packages/opencode/src/altimate/tools/tool-lookup.ts +++ b/packages/opencode/src/altimate/tools/tool-lookup.ts @@ -51,7 +51,7 @@ interface ParamInfo { description: string } -function describeJsonSchema(schema: JSONSchema7 | undefined): ParamInfo[] { +export function describeJsonSchema(schema: JSONSchema7 | undefined): ParamInfo[] { if (!schema || typeof schema !== "object") return [] const properties = schema.properties if (!properties) return [] @@ -91,3 +91,5 @@ function inferJsonType(field: JSONSchema7Definition): string { if (typeof type === "string") return type return "unknown" } + +export * as ToolLookup from "./tool-lookup" diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 62804161a..030f5af63 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -24,6 +24,9 @@ import { // altimate_change end // altimate_change start — tool retrieval import { Retrieval } from "@/tool/retrieval" +// reroute calls to tools reachable only through tool_run +import { ToolSelection } from "@/altimate/tool-selection" +import { ToolRun } from "@/altimate/tool-run" // altimate_change end import { Config } from "@/config/config" import { Instance } from "@/project/instance" @@ -223,13 +226,14 @@ export namespace LLM { // tools absent from the current set. Add stub definitions for any missing tools. // Fixes: https://github.com/AltimateAI/altimate-code/issues/678 const referencedTools = toolNamesFromMessages(input.messages) - addHistoricalToolStubs(tools, referencedTools, input.toolChoice) + addHistoricalToolStubs(tools, referencedTools, input.toolChoice, ToolSelection.hiddenFor(tools[ToolSelection.TOOL_RUN])) // altimate_change end // altimate_change start — tool retrieval // Expose only the relevant top-k tools this turn (flag-gated). Keeps the // always-on core + any in-flight (referenced) tools; no-op for small sets. - if (Retrieval.enabled()) { + // A session whose list was decided once at start (it has the generated tool_run) is not re-trimmed per turn. + if (Retrieval.enabled() && !ToolSelection.hiddenFor(tools[ToolSelection.TOOL_RUN])) { const lastUser = [...input.messages].reverse().find((m) => m.role === "user") const c = lastUser?.content as any const query = @@ -299,6 +303,14 @@ export namespace LLM { toolName: lower, } } + // altimate_change start — a call addressed to a tool that is reachable only through `tool_run` + // is run there instead of failing + const rerouted = rerouteHiddenCall(tools, failed.toolCall) + if (rerouted) { + l.info("rerouting call to tool_run", { tool: failed.toolCall.toolName }) + return rerouted + } + // altimate_change end return { ...failed.toolCall, input: JSON.stringify({ @@ -372,8 +384,20 @@ export namespace LLM { }) } - async function resolveTools(input: Pick) { + // altimate_change start — exported for tests + /** @internal Exported for testing */ + export async function resolveTools(input: Pick) { + // altimate_change end const disabled = PermissionNext.disabled(Object.keys(input.tools), input.agent.permission) + // altimate_change start — `tool_run` carries only the targets the agent may use (decided when the + // tools were resolved), so a wildcard deny that leaves a few tools allowed must not remove it. A rule + // that names `tool_run` itself, or a per-message toggle, still does. + // Only the generated router is exempt, and only from the catch-all (see ToolSelection.routerAllowed). + const generated = ToolSelection.hiddenFor(input.tools[ToolSelection.TOOL_RUN]) !== undefined + if (generated && disabled.has(ToolSelection.TOOL_RUN) && ToolSelection.routerAllowed(input.agent.permission)) { + disabled.delete(ToolSelection.TOOL_RUN) + } + // altimate_change end for (const tool of Object.keys(input.tools)) { if (input.user.tools?.[tool] === false || disabled.has(tool)) { delete input.tools[tool] @@ -382,6 +406,33 @@ export namespace LLM { return input.tools } + // altimate_change start — reroute a call addressed to a tool that is reachable only through `tool_run` + export function rerouteHiddenCall( + tools: Record, + call: C, + ): C | undefined { + const hidden = ToolSelection.hiddenFor(tools[ToolSelection.TOOL_RUN]) + if (!hidden) return undefined + // A name the request already offers is not rerouted, whatever hidden tool has a similar spelling. + if (Object.hasOwn(tools, call.toolName)) return undefined + // Same repair offered tools get for a wrongly cased name. + const name = Object.hasOwn(hidden, call.toolName) ? call.toolName : call.toolName.toLowerCase() + if (!Object.hasOwn(hidden, name)) return undefined + // Input that is not valid JSON is not guessed at: an empty argument object could run a tool with + // its defaults. The call falls through to the normal invalid-call path instead. + let args: unknown + try { + args = JSON.parse(call.input) + } catch { + return undefined + } + // Arguments must be an object, as they must be for the tool itself. + if (typeof args !== "object" || args === null || Array.isArray(args)) return undefined + ToolRun.markRerouted(call.toolCallId) + return { ...call, toolName: ToolSelection.TOOL_RUN, input: JSON.stringify({ name, arguments: args }) } + } + // altimate_change end + // altimate_change start — collect tool names from message history to prevent API validation errors // Anthropic API requires every tool_use block in message history to have a matching tool // definition. When agents switch (e.g. Plan→Builder) or MCP tools disconnect, the history @@ -427,11 +478,25 @@ export namespace LLM { tools: Record, referenced: Iterable, toolChoice?: "auto" | "required" | "none", + // a history entry for a tool that is now reachable only through `tool_run` runs that tool, + // instead of answering that it is gone + reachable?: Record, ) { if (toolChoice === "none" && Object.keys(tools).length === 0) return tools // altimate_change end for (const name of referenced) { if (!Object.hasOwn(tools, name)) { + // altimate_change start + if (reachable && Object.hasOwn(reachable, name)) { + // Definition only for the API's sake; the real tool runs. Kept small: the schema is open. + tools[name] = tool({ + description: `[Historical] ${name}; call it through tool_run`, + inputSchema: jsonSchema({ type: "object", additionalProperties: true }), + execute: reachable[name]!.execute as never, + }) + continue + } + // altimate_change end tools[name] = tool({ description: `[Historical] Tool no longer available in this session`, inputSchema: jsonSchema({ type: "object", properties: {} }), diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index ada99b345..144e91988 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -28,6 +28,7 @@ import { ProviderTransform } from "../provider/transform" import { SystemPrompt } from "./system" import { InstructionPrompt } from "./instruction" import { MemoryPrompt } from "../memory/prompt" +import { MemoryStore } from "../memory/store" import { UNIFIED_INJECTION_BUDGET } from "../memory/types" // altimate_change - workspace memory read path import * as WorkspaceMemory from "../altimate/workspace/memory-sync" @@ -99,6 +100,12 @@ import { sanitizeTelemetryDetails } from "../altimate/validators/validator-utils // bundler cannot tree-shake the validator registrations. registerAltimateValidators() import { Config } from "../config/config" +// altimate_change start — smaller default tool list with a fixed `tool_run` path to the rest +import { ToolSelection } from "../altimate/tool-selection" +import { Global } from "@/global" +import { ToolRun } from "../altimate/tool-run" +import { Wildcard } from "@/util/wildcard" +// altimate_change end import { Tracer } from "../altimate/observability/tracing" // altimate_change start — stamp an authoritative tool source + humanized MCP title import { stampRegistryToolSource, describeMcpTool } from "../altimate/tool-source" @@ -2271,6 +2278,13 @@ export namespace SessionPrompt { } // altimate_change end + // altimate_change start — forget a session's tool-list decision when the session is deleted + const releaseDecisionsOnDelete = Instance.state( + () => ({ unsubscribe: Bus.subscribe(Session.Event.Deleted, (evt) => ToolSelection.forget(evt.properties.info.id)) }), + async (state) => state.unsubscribe(), + ) + // altimate_change end + /** @internal Exported for testing */ export async function resolveTools(input: { agent: Agent.Info @@ -2286,6 +2300,10 @@ export namespace SessionPrompt { }) { using _ = log.time("resolveTools") const tools: Record = {} + // altimate_change start — smaller default tool list: the rules a tool the list rule governs is held to when it runs + const smallerToolList = ToolSelection.smallerToolListEnabled((await Config.get()).experimental?.smaller_tool_list) + const governedRules = smallerToolList ? PermissionNext.merge(input.agent.permission, input.session.permission ?? []) : [] + // altimate_change end // altimate_change start — carry tool identity into repeated-id metadata lookup const context = (toolName: string, args: any, options: ToolCallOptions) => { @@ -2354,10 +2372,18 @@ export namespace SessionPrompt { ) // altimate_change end + // altimate_change start — built-in tool ids only: custom and plugin tools are the user's and stay offered + const builtinIds: string[] = [] + const externalIds = new Set() + // altimate_change end for (const item of await ToolRegistry.tools( { modelID: ModelID.make(input.model.api.id), providerID: input.model.providerID }, input.agent, )) { + // altimate_change start + if (item.registrySource === "external") externalIds.add(item.id) + else builtinIds.push(item.id) + // altimate_change end // altimate_change start — v1.17.9: tool parameters are Effect Schema; derive JSON Schema via ToolJsonSchema const schema = ProviderTransform.schema(input.model, ToolJsonSchema.fromTool(item)) // altimate_change end @@ -2373,6 +2399,12 @@ export namespace SessionPrompt { // altimate_change end // altimate_change start — release the execution identity on every exit path try { + // altimate_change start — a governed tool the rules deny is refused whether it is offered directly + // or reached through `tool_run`; a prompt the tool itself raises is unchanged + if (smallerToolList && ToolSelection.isOptional(item.id) && ToolSelection.deniedForAll(item.id, governedRules)) { + throw new PermissionNext.DeniedError(governedRules.filter((rule) => Wildcard.match(item.id, rule.permission))) + } + // altimate_change end await Plugin.trigger( "tool.execute.before", { @@ -2549,6 +2581,43 @@ export namespace SessionPrompt { tools[key] = item } + // altimate_change start — smaller default tool list. Which optional tools are offered is decided once + // per session from project facts; the rest are reached through `tool_run`, whose definition never + // changes (see altimate/tool-selection.ts for the prompt-cache contract). + if (smallerToolList) { + releaseDecisionsOnDelete() + const on = await ToolSelection.decide(input.session.id, () => + ToolSelection.detectFacts({ + directory: Instance.directory, + root: Instance.worktree, + home: Global.Path.home, + memoryPresent: async () => (await MemoryStore.listAll()).length > 0, + }), + ) + // A user's tool that replaces a built-in id is theirs: it is never hidden. That includes a tool from an + // MCP server whose prefixed name lands on a built-in id, since the server's tool replaced the built-in. + for (const key of Object.keys(mcpTools)) externalIds.add(key) + const hideable = builtinIds.filter((id) => !externalIds.has(id)) + // Session rules count as well as the agent's: a target the session denies stays out of the router. + const rules = PermissionNext.merge(input.agent.permission, input.session.permission ?? []) + const denied = PermissionNext.disabled(hideable, rules) + const hidden: Record = {} + // A user's own tool that is already named `tool_run` is left alone, and so is everything when the rules + // would switch the router off: its targets then stay in the direct list. + // (`LLM.resolveTools` filters the router by the agent's rules, so both the agent's and the merged rules must allow it.) + const hideNothing = + ToolSelection.TOOL_RUN in tools || + !ToolSelection.routerAllowed(input.agent.permission) || + !ToolSelection.routerAllowed(rules) + for (const id of hideNothing ? [] : ToolSelection.hiddenIds(hideable, on, input.agent.prompt ?? "")) { + const hiddenTool = tools[id] + delete tools[id] + if (hiddenTool && !denied.has(id) && input.tools?.[id] !== false) hidden[id] = hiddenTool + } + if (Object.keys(hidden).length > 0) tools[ToolSelection.TOOL_RUN] = ToolRun.createRunTool({ hidden }) + } + // altimate_change end + return tools } diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index 51f1d1673..6d087b8fa 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -27,6 +27,7 @@ import { Skill } from "@/skill" import { Fingerprint } from "../altimate/fingerprint" import { Config } from "../config/config" import { selectSkillsWithLLM } from "../altimate/skill-selector" +import { SkillListing } from "../altimate/skill-listing" // altimate_change start — Effect Service facade for SystemPrompt.skills (see bottom of namespace) import { Context, Effect, Layer } from "effect" import { LayerNode } from "@opencode-ai/core/effect/layer-node" @@ -175,7 +176,13 @@ export namespace SystemPrompt { "Use the skill tool to load a skill when a task matches its description.", // the agents seem to ingest the information about skills a bit better if we present a more verbose // version of them here and a less verbose version in tool description, rather than vice versa. - Skill.fmt(filtered, { verbose: true }), + // altimate_change start — bounded listing keeps this block inside a token budget + SkillListing.boundedSkillListingEnabled(cfg.experimental?.bounded_skill_listing) + ? filtered.length === 0 + ? "No skills are currently available." + : SkillListing.renderBoundedListing(filtered, "system") + : Skill.fmt(filtered, { verbose: true }), + // altimate_change end ) return parts.join("\n") } diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index 978c716de..4331d3665 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -19,6 +19,8 @@ import { selectSkillsWithLLM } from "../altimate/skill-selector" import { Telemetry } from "../altimate/telemetry" import os from "os" +import { SkillListing } from "../altimate/skill-listing" + const MAX_DISPLAY_SKILLS = 50 // altimate_change start — classifySkillSource helper for skill telemetry + source badge @@ -81,9 +83,9 @@ export function resolveSkillBase(location: string): { isBuiltin: boolean; dir: s // string). So: advertise only names the neutralizer leaves untouched, which are // exactly the ones both copyable and free of trust-tag text. The authoritative // listing carries every skill, escaped. -export function selectExampleNames(skills: Skill.Info[]): string { +export function selectExampleNames(skills: Skill.Info[], maxNameLength = Infinity): string { return skills - .filter((skill) => Skill.neutralizeSkillNameText(skill.name) === skill.name) + .filter((skill) => Skill.neutralizeSkillNameText(skill.name) === skill.name && skill.name.length <= maxNameLength) .map((skill) => `'${skill.name}'`) .slice(0, 3) .join(", ") @@ -139,6 +141,9 @@ export function renderAvailableSkills(skills: Skill.Info[]): string[] { // altimate_change end export const SkillTool = Tool.define("skill", async (ctx) => { + // altimate_change start — the agent this tool was built for, used again when a lookup misses + const initAgent = ctx?.agent + // altimate_change end const list = await Skill.available(ctx?.agent) // altimate_change start — LLM-based dynamic skill selection and learning kill switch @@ -152,8 +157,10 @@ export const SkillTool = Tool.define("skill", async (ctx) => { } else { allAllowed = enabledSkills } - const displaySkills = allAllowed.slice(0, MAX_DISPLAY_SKILLS) - const hasMore = allAllowed.length > displaySkills.length + // bounded listing: every skill is reachable within a token budget (see altimate/skill-listing.ts) + const bounded = SkillListing.boundedSkillListingEnabled(cfg.experimental?.bounded_skill_listing) + const displaySkills = bounded ? allAllowed : allAllowed.slice(0, MAX_DISPLAY_SKILLS) + const hasMore = !bounded && allAllowed.length > displaySkills.length // altimate_change end // altimate_change start - use displaySkills (filtered) instead of list @@ -172,7 +179,7 @@ export const SkillTool = Tool.define("skill", async (ctx) => { "The following skills provide specialized sets of instructions for particular tasks", "Invoke this tool to load a skill when a task matches one of the available skills listed below:", "", - ...renderAvailableSkills(displaySkills), + ...(bounded ? [SkillListing.renderBoundedListing(displaySkills, "tool")] : renderAvailableSkills(displaySkills)), // altimate_change start - add hint when skills are truncated ...(hasMore ? [ @@ -185,7 +192,9 @@ export const SkillTool = Tool.define("skill", async (ctx) => { // altimate_change end // altimate_change start - use displaySkills for examples - const examples = selectExampleNames(displaySkills) + // altimate_change start — in bounded mode an example name is short, so the parameter description stays small + const examples = selectExampleNames(bounded ? SkillListing.orderSkills(displaySkills) : displaySkills, bounded ? 40 : Infinity) + // altimate_change end const hint = examples.length > 0 ? ` (e.g., ${examples}, ...)` : "" // altimate_change end @@ -205,9 +214,17 @@ export const SkillTool = Tool.define("skill", async (ctx) => { const learningEnabled = learnEnabled((await Config.get()).learn) if (!skill || (!learningEnabled && skill.content.includes(LEARN_MANAGED_HEADER))) { - const available = await Skill.all().then((skills) => skills - .filter((item) => learningEnabled || !item.content.includes(LEARN_MANAGED_HEADER)) - .map((item) => item.name).join(", ")) + if (bounded) { + // Search what the agent may use now, not the list read when the tool was initialised. + const current = await Skill.available(initAgent).then((skills) => + skills.filter((item) => learningEnabled || !item.content.includes(LEARN_MANAGED_HEADER)), + ) + throw new Error(SkillListing.notFoundMessage(current, params.name)) + } + const visible = await Skill.all().then((skills) => + skills.filter((item) => learningEnabled || !item.content.includes(LEARN_MANAGED_HEADER)), + ) + const available = visible.map((item) => item.name).join(", ") throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`) } // altimate_change end diff --git a/packages/opencode/test/altimate/skill-listing.test.ts b/packages/opencode/test/altimate/skill-listing.test.ts new file mode 100644 index 000000000..ab29b08a9 --- /dev/null +++ b/packages/opencode/test/altimate/skill-listing.test.ts @@ -0,0 +1,335 @@ +import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test" + +setDefaultTimeout(30_000) +import path from "path" +import { Instance } from "../../src/project/instance" +import { SkillTool } from "../../src/tool/skill" +import { SkillListing } from "../../src/altimate/skill-listing" + +const { + boundedSkillListingEnabled, + findSkills, + notFoundMessage, + orderSkills, + renderBoundedListing, + SYSTEM_LISTING_BUDGET_TOKENS, + TOOL_LISTING_BUDGET_TOKENS, +} = SkillListing +import { tmpdir, provideTestInstance } from "../fixture/fixture" +import { initTool } from "./tool-fixture" +import { SessionID, MessageID } from "../../src/session/schema" + +const KEY = "ALTIMATE_BOUNDED_SKILL_LISTING" +const tokens = (text: string) => Math.ceil(text.length / 4) + +function synthetic(count: number, description = "Use this skill when the task involves ".concat("warehouse cost review ".repeat(8))) { + return Array.from({ length: count }, (_, i) => ({ + name: `skill-${String(i).padStart(4, "0")}-${["snowflake", "dbt", "airflow", "lineage", "pii"][i % 5]}`, + description: `${description} (${i})`, + location: `/skills/skill-${i}/SKILL.md`, + })) +} + +const ORIGINAL_SWITCH = process.env[KEY] +afterEach(() => { + if (ORIGINAL_SWITCH === undefined) delete process.env[KEY] + else process.env[KEY] = ORIGINAL_SWITCH +}) + +describe("bounded skill listing", () => { + test("stays under the budget with hundreds of skills, for both listings", () => { + for (const count of [1, 10, 50, 300, 1000]) { + const skills = synthetic(count) + const tool = renderBoundedListing(skills, "tool") + const system = renderBoundedListing(skills, "system") + // The budget bounds the entries; the wrapper and footer add a small fixed amount. + expect(tokens(tool)).toBeLessThanOrEqual(TOOL_LISTING_BUDGET_TOKENS + 80) + expect(tokens(system)).toBeLessThanOrEqual(SYSTEM_LISTING_BUDGET_TOKENS + 80) + } + }) + + test("a short list keeps long descriptions; a long list shortens them evenly", () => { + const long = "x".repeat(300) + const few = renderBoundedListing(synthetic(5, long), "system") + expect(few).toContain("x".repeat(300)) + const many = renderBoundedListing(synthetic(40, long), "system") + expect(many).not.toContain("x".repeat(300)) + expect(many).toContain("x".repeat(100)) + // every skill still has a description at the shared cap + expect(many.split("\n").filter((l) => l.includes("xxxx")).length).toBe(40) + }) + + test("a denied skill tool gets no listing; an empty bounded listing is an empty wrapper", async () => { + const { SystemPrompt } = await import("../../src/session/system") + const agent = { name: "build", mode: "primary", permission: [{ permission: "skill", pattern: "*", action: "deny" }], options: {} } as any + expect(await SystemPrompt.skills(agent)).toBeUndefined() + expect(renderBoundedListing([], "system")).toBe("\n") + }) + + test("size does not keep growing past the budget", () => { + const small = renderBoundedListing(synthetic(300), "tool").length + const large = renderBoundedListing(synthetic(3000), "tool").length + // The footer's count grows by a digit, nothing else. + expect(large - small).toBeLessThan(20) + }) + + test("a short list gets every description", () => { + const text = renderBoundedListing(synthetic(3, "Short."), "tool") + expect(text).toContain("skill-0000-snowflake: Short. (0)") + expect(text).toContain("skill-0002-airflow: Short. (2)") + expect(text).not.toContain("more installed skills") + }) + + test("the listing is identical whatever order the skills are discovered in", () => { + const skills = synthetic(120) + const shuffled = [...skills].sort((a, b) => (a.name.length + a.location.length) % 7 - (b.name.length + b.location.length) % 7 || b.name.localeCompare(a.name)) + expect(renderBoundedListing(shuffled, "tool")).toBe(renderBoundedListing(skills, "tool")) + expect(renderBoundedListing(shuffled, "system")).toBe(renderBoundedListing(skills, "system")) + expect(orderSkills(shuffled).map((s) => s.name)).toEqual(orderSkills(skills).map((s) => s.name)) + }) + + test("embedded skills are listed before the rest", () => { + const skills = [ + { name: "aaa-project", description: "project", location: "/p/aaa/SKILL.md" }, + { name: "zzz-builtin", description: "builtin", location: "builtin:zzz-builtin/SKILL.md" }, + ] + expect(orderSkills(skills).map((s) => s.name)).toEqual(["zzz-builtin", "aaa-project"]) + }) + + test("every skill is either listed or found by searching its own name", () => { + const skills = synthetic(400) + const tool = renderBoundedListing(skills, "tool") + const hidden = skills.filter((s) => !tool.includes(s.name)) + expect(hidden.length).toBeGreaterThan(0) // the case under test: the listing really is truncated + expect(tool).toContain(`${skills.length} in total`) + for (const skill of skills) { + const found = findSkills(skills, skill.name) + expect(found[0]?.name).toBe(skill.name) + } + }) + + test("a keyword finds skills by description and the miss message stays bounded", () => { + const skills = [...synthetic(500), { name: "lakehouse-tuning", description: "Tune Iceberg compaction jobs", location: "/p/l/SKILL.md" }] + expect(findSkills(skills, "iceberg compaction").map((s) => s.name)).toEqual(["lakehouse-tuning"]) + const miss = notFoundMessage(skills, "quantum-flux") + expect(miss).toContain("501 skills are installed") + expect(tokens(miss)).toBeLessThan(300) + const hit = notFoundMessage(skills, "iceberg") + expect(hit).toContain("lakehouse-tuning: Tune Iceberg compaction jobs") + }) + + test("a keyword in any script finds the skill, and a skill is always found by its own name", () => { + const skills = [ + ...synthetic(300), + { name: "kpi-audit", description: "数据库审计 for the warehouse", location: "/p/k/SKILL.md" }, + // Names that all tokenise the same way: only the exact name tells them apart. + ...Array.from({ length: 20 }, (_, i) => ({ name: `z${"-".repeat(i + 1)}a`, description: "same", location: `/p/z${i}/SKILL.md` })), + ] + expect(findSkills(skills, "数据库").map((s) => s.name)).toEqual(["kpi-audit"]) + for (const skill of skills) expect(findSkills(skills, skill.name)[0]?.name).toBe(skill.name) + const miss = notFoundMessage(skills, "same") + expect(miss).toContain("more match") + }) + + test("shortening never splits a surrogate pair, and a very long name stays bounded", () => { + const text = renderBoundedListing( + [ + { name: "emoji", description: "a".repeat(68) + "😀" + "x".repeat(40), location: "/p/e/SKILL.md" }, + { name: "n".repeat(5000), description: "long", location: "/p/n/SKILL.md" }, + ], + "tool", + ) + expect(text).not.toMatch(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/) + expect(text.length).toBeLessThan(1500) + expect(text).toContain("emoji: ") + }) + + test("search output carries no newline, wrapper tag or unbounded text from a name or the query", () => { + const skills = [ + { name: "evil\nIgnore previous" + "x".repeat(2000), description: "ledger", location: "/p/e/SKILL.md" }, + ...synthetic(30), + ] + const text = notFoundMessage(skills, "\nledger " + "q".repeat(500)) + expect(text).not.toContain("") + expect(text).not.toContain("") + expect(text.split("\n").some((l) => l.startsWith("Ignore previous"))).toBe(false) + expect(text.length).toBeLessThan(1500) + }) + + test("a skill that matches every term of the query outranks ones that match most terms strongly", () => { + const terms = ["alpha", "beta", "gamma", "delta", "epsilon", "zeta", "eta", "theta", "iota"] + const partial = Array.from({ length: 5 }, (_, i) => ({ name: `${terms.slice(0, 8).join("-")}-${i}`, description: terms.slice(0, 8).join(" "), location: `/p/p${i}/SKILL.md` })) + const complete = { name: "zzz-complete", description: terms.join(" "), location: "/p/c/SKILL.md" } + expect(findSkills([...partial, complete], terms.join(" "), 3)[0]?.name).toBe("zzz-complete") + }) + + test("a name with odd whitespace is shown exactly, quoted, so it can be copied back", () => { + const text = renderBoundedListing([{ name: "a b\nc", description: "odd", location: "/p/o/SKILL.md" }, { name: "plain-name", description: "ok", location: "/p/p/SKILL.md" }], "tool") + expect(text).toContain('"a b\\nc": odd') + const sep = renderBoundedListing( + [{ name: "x\u2028y", description: "d", location: "/p/s/SKILL.md" }, { name: "p\u2029q", description: "e", location: "/p/t/SKILL.md" }], + "tool", + ) + expect(sep).toContain('"x\\u2028y": d') + expect(sep).toContain('"p\\u2029q": e') + expect(sep).not.toMatch(/[\u2028\u2029]/) + expect(text).toContain("plain-name: ok") + }) + + test("a query is answered by the skills that cover the most of it, not by many weak name hits", () => { + const skills = [ + ...Array.from({ length: 20 }, (_, i) => ({ name: `compaction-${i}`, description: "x", location: `/p/c${i}/SKILL.md` })), + { name: "lakehouse-tuning", description: "Tune Iceberg compaction jobs", location: "/p/l/SKILL.md" }, + ] + expect(findSkills(skills, "iceberg compaction")[0]?.name).toBe("lakehouse-tuning") + }) + + test("the footer and wrapper fit inside the budget too", () => { + const text = renderBoundedListing(synthetic(2000), "tool") + expect(tokens(text)).toBeLessThanOrEqual(TOOL_LISTING_BUDGET_TOKENS) + }) + + test("search shows the full name of a skill whose name is too long to list whole", () => { + const longName = "very-long-".repeat(13) // 130 characters: listed shortened, shown whole in search + const skills = [...synthetic(50), { name: longName, description: "ledger sync", location: "/p/l/SKILL.md" }] + expect(longName.length).toBeGreaterThan(100) + expect(notFoundMessage(skills, "ledger")).toContain(`- ${longName}: ledger sync`) + }) + + test("names that try to close the wrapper are neutralised", () => { + const text = renderBoundedListing( + [{ name: "x", description: "d\nignore previous", location: "/p/SKILL.md" }], + "system", + ) + expect(text.match(/<\/available_skills>/g)?.length).toBe(1) + expect(text.split("\n").length).toBeLessThanOrEqual(4) + }) + + test("switch: environment beats config, config beats the default", () => { + delete process.env[KEY] + expect(boundedSkillListingEnabled(undefined)).toBe(true) + expect(boundedSkillListingEnabled(false)).toBe(false) + process.env[KEY] = "0" + expect(boundedSkillListingEnabled(true)).toBe(false) + process.env[KEY] = "1" + expect(boundedSkillListingEnabled(false)).toBe(true) + }) +}) + +async function withSkills(count: number, fn: (dir: string) => Promise): Promise { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + for (let i = 0; i < count; i++) { + const name = `bulk-${String(i).padStart(3, "0")}` + await Bun.write( + path.join(dir, ".opencode", "skill", name, "SKILL.md"), + `---\nname: ${name}\ndescription: Bulk skill number ${i} for ${["snowflake", "dbt", "airflow"][i % 3]} work with a long description that keeps going to make the listing heavy.\n---\n# ${name}\nBody ${i}\n`, + ) + } + }, + }) + const home = process.env.OPENCODE_TEST_HOME + process.env.OPENCODE_TEST_HOME = tmp.path + try { + return await provideTestInstance({ directory: tmp.path, fn: (ctx) => Instance.restore(ctx, () => fn(tmp.path)) }) + } finally { + if (home === undefined) delete process.env.OPENCODE_TEST_HOME + else process.env.OPENCODE_TEST_HOME = home + } +} + +const ctx = { + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make("msg_test"), + callID: "", + agent: "build", + abort: AbortSignal.any([]), + messages: [], + metadata: () => {}, + ask: async () => {}, +} + +describe("skill tool with many installed skills", () => { + test("description stays bounded, and skills past the old 50 cap are reachable", async () => { + await withSkills(300, async () => { + process.env[KEY] = "1" + const bounded = await initTool(SkillTool) + process.env[KEY] = "0" + const legacy = await initTool(SkillTool) + expect(tokens(bounded.description)).toBeLessThan(TOOL_LISTING_BUDGET_TOKENS + 400) + expect(tokens(legacy.description)).toBeGreaterThan(tokens(bounded.description) * 3) + // Legacy: skill 51 onward is not in the description at all. + expect(legacy.description).not.toContain("bulk-200") + // Bounded: it is searchable by keyword, and loadable by exact name. + process.env[KEY] = "1" + const err = await bounded.execute({ name: "bulk-200" }, ctx).then( + (r: any) => r, + (e: Error) => e, + ) + expect(err).not.toBeInstanceOf(Error) + expect(err.output).toContain('name="bulk-200"') + const miss = await bounded.execute({ name: "airflow" }, ctx).then( + () => undefined, + (e: Error) => e.message, + ) + expect(miss).toContain("301 skills are installed") + expect(miss).toContain("Closest matches") + }) + }) + + test("a skill hidden by the budget is discovered by keyword, then loaded by name", async () => { + await withSkills(500, async (dir) => { + await Bun.write( + path.join(dir, ".opencode", "skill", "zzz-reconcile", "SKILL.md"), + `---\nname: zzz-reconcile\ndescription: Reconcile ledger exports against invoices\n---\n# Reconcile\nSteps\n`, + ) + process.env[KEY] = "1" + const tool = await initTool(SkillTool) + expect(tool.description).not.toContain("zzz-reconcile") // really hidden + expect(tool.description).toContain("502 in total") + const miss = await tool.execute({ name: "ledger invoices" }, ctx).then( + () => "", + (e: Error) => e.message, + ) + expect(miss).toContain("zzz-reconcile: Reconcile ledger exports against invoices") + const loaded = await tool.execute({ name: "zzz-reconcile" }, ctx) + expect(loaded.output).toContain("Steps") + }) + }) + + test("description is byte-identical across two initialisations", async () => { + await withSkills(120, async () => { + process.env[KEY] = "1" + const a = await initTool(SkillTool) + const b = await initTool(SkillTool) + expect(a.description).toBe(b.description) + }) + }) + + test("switch off restores the legacy listing", async () => { + await withSkills(3, async () => { + process.env[KEY] = "0" + const legacy = await initTool(SkillTool) + expect(legacy.description).toContain("bulk-000") + process.env[KEY] = "1" + const bounded = await initTool(SkillTool) + expect(bounded.description).not.toContain("bulk-000") + expect(bounded.description).toContain("bulk-000: Bulk skill number 0") + }) + }) +}) + +describe("system prompt listing", () => { + test("is bounded and complete by default, and says so when nothing is installed", async () => { + const { SystemPrompt } = await import("../../src/session/system") + await withSkills(400, async () => { + const text = (await SystemPrompt.skills({ name: "build", mode: "primary", permission: [], options: {} } as any)) ?? "" + expect(tokens(text)).toBeLessThan(SYSTEM_LISTING_BUDGET_TOKENS + 200) + expect(text).toContain("bulk-399") + process.env[KEY] = "0" + const legacy = (await SystemPrompt.skills({ name: "build", mode: "primary", permission: [], options: {} } as any)) ?? "" + expect(tokens(legacy)).toBeGreaterThan(tokens(text) * 5) + }) + }) +}) diff --git a/packages/opencode/test/altimate/tool-selection.test.ts b/packages/opencode/test/altimate/tool-selection.test.ts new file mode 100644 index 000000000..11315f3fb --- /dev/null +++ b/packages/opencode/test/altimate/tool-selection.test.ts @@ -0,0 +1,648 @@ +import { afterEach, beforeEach, describe, expect, setDefaultTimeout, test } from "bun:test" +import path from "path" +import { Instance } from "../../src/project/instance" +import { SessionPrompt } from "../../src/session/prompt" +import { ToolRegistry } from "../../src/tool/registry" +import { LLM } from "../../src/session/llm" +import { FRAGMENTS } from "../../src/altimate/prompts/profiles" +import { ToolSelection as TS } from "../../src/altimate/tool-selection" +import { Dispatcher } from "../../src/altimate/native" +import { tmpdir, provideTestInstance } from "../fixture/fixture" + +// The first call into the native SQL engine loads it, which is slow on a loaded machine. +setDefaultTimeout(30_000) + +const KEY = "ALTIMATE_SMALLER_TOOL_LIST" +const ORIGINAL_SWITCH = process.env[KEY] + +// The hidden-tool target used below calls the native SQL engine. Other test files reset the shared +// dispatcher, so register a deterministic stand-in instead of depending on test order. +function standInForNativeImportDdl() { + // Clearing the lazy registration hook as well, or the first call would install the real handler over the stand-in. + Dispatcher.reset() + Dispatcher.register("altimate_core.import_ddl" as any, (async (params: { ddl: string }) => ({ + success: true, + data: { schema: { parsed_from: params.ddl } }, + })) as any) +} + +/** Ids that are always offered: every tool the groups do not list, plus the ones the instructions name. */ +const ALWAYS = ["bash", "read", "glob", "grep", "edit", "write", "task", "todowrite", "skill", "webfetch", "tool_lookup", "invalid"] + +const STATES: TS.Facts[] = [] +for (const dbtProject of [false, true]) + for (const sqlFiles of [false, true]) + for (const warehouse of [false, true]) + for (const memory of [false, true]) STATES.push({ dbtProject, sqlFiles, warehouse, memory }) + +beforeEach(standInForNativeImportDdl) + +afterEach(() => { + if (ORIGINAL_SWITCH === undefined) delete process.env[KEY] + else process.env[KEY] = ORIGINAL_SWITCH + TS.reset() +}) + +describe("tool selection rule", () => { + test("core tools are offered in every project state", async () => { + const ids = (await registryIds()).filter((id) => id !== "invalid") + for (const facts of STATES) { + const hidden = new Set(TS.hiddenIds(ids, TS.groupsFor(facts))) + for (const id of ALWAYS) expect(hidden.has(id)).toBe(false) + } + }) + + test("same facts give the same hidden set and the same tool_run definition", async () => { + const ids = await registryIds() + const facts = { dbtProject: true, sqlFiles: true, warehouse: false, memory: false } + const a = TS.hiddenIds(ids, TS.groupsFor(facts)) + const b = TS.hiddenIds(ids, TS.groupsFor({ ...facts })) + expect(b).toEqual(a) + expect(TS.runDescription(b)).toBe(TS.runDescription(a)) + // The description lists tools by group, so it does not depend on the order the registry returned them in. + expect(TS.runDescription([...a].reverse())).toBe(TS.runDescription(a)) + }) + + test("a tool the groups do not know is never hidden", () => { + expect(TS.hiddenIds(["brand_new_tool", "custom_plugin_tool"], TS.groupsFor({ dbtProject: false, sqlFiles: false, warehouse: false, memory: false }))).toEqual([]) + }) + + test("every grouped id is a real registered tool (the groups cannot drift from the registry)", async () => { + const ids = new Set(await registryIds()) + const optionalOnlyWhenFlagged = new Set(["altimate_memory_extract", "altimate_memory_refresh"]) + for (const def of Object.values(TS.GROUPS)) + for (const id of def.tools) if (!optionalOnlyWhenFlagged.has(id)) expect(ids.has(id)).toBe(true) + for (const id of TS.NAMED_BY_PROMPT) expect(ids.has(id)).toBe(true) + }) + + test("no tool is in two groups", () => { + const seen = new Set() + for (const def of Object.values(TS.GROUPS)) + for (const id of def.tools) { + expect(seen.has(id)).toBe(false) + seen.add(id) + } + }) + + test("every optional tool the builder instructions name is offered in every project state", async () => { + const instructions = ["core", "core-training", "dbt-ops", "sql-guard", "dbt-verify", "dbt-workflow", "pitfalls", "self-review", "legacy-skills-catalogue", "finish"] + .map((name) => FRAGMENTS[name as keyof typeof FRAGMENTS]) + .join("\n") + const ids = (await registryIds()).filter((id) => id !== "invalid") + const named = ids.filter((id) => new RegExp(`\\b${id}\\b`).test(instructions)) + expect(named.length).toBeGreaterThan(8) // the scan really finds the tools the packs name + for (const facts of STATES) { + const hidden = new Set(TS.hiddenIds(ids, TS.groupsFor(facts))) + for (const id of named) expect(hidden.has(id)).toBe(false) + } + }) + + test("project facts: dbt in a subfolder is found; dependency folders and dot-folders are not searched", async () => { + const mk = async (files: Record) => + inProject( + async (dir) => { + for (const [name, body] of Object.entries(files)) await Bun.write(path.join(dir, name), body) + }, + (dir) => TS.detectFacts({ directory: dir, home: path.join(dir, "nohome"), memoryPresent: async () => false }), + ) + expect(await mk({ "analytics/dbt_project.yml": "name: a\n" })).toMatchObject({ dbtProject: true }) + expect(await mk({ "src/sql/reports/q.sql": "select 1\n" })).toMatchObject({ dbtProject: false, sqlFiles: true }) + expect(await mk({ "node_modules/x/dbt_project.yml": "a", ".hidden/dbt_project.yml": "a", "a/b/c/d/dbt_project.yml": "a" })).toMatchObject({ dbtProject: false }) + expect(await mk({ "readme.md": "hi" })).toMatchObject({ dbtProject: false, sqlFiles: false, warehouse: false }) + expect(await mk({ ".altimate-code/connections.json": '{"wh":{"type":"duckdb"}}' })).toMatchObject({ warehouse: true }) + expect(await mk({ ".altimate-code/connections.json": "{}" })).toMatchObject({ warehouse: false }) + }) + + test("a tool the agent's own instructions name is offered whatever the project looks like", () => { + const on = TS.groupsFor({ dbtProject: false, sqlFiles: false, warehouse: false, memory: false }) + const ids = ["finops_query_history", "finops_role_grants", "schema_tags", "altimate_core_fingerprint"] + expect(TS.hiddenIds(ids, on)).toEqual(ids) + const hidden = TS.hiddenIds(ids, on, "Use finops_query_history for spend. Also call schema_tags_list and finops_role_grants_extra.") + expect(hidden).toEqual(["finops_role_grants", "schema_tags", "altimate_core_fingerprint"]) // whole-word match only + }) + + test("project facts: a large folder does not use up the scan before a sibling is reached", async () => { + const found = await inProject( + async (dir) => { + for (let i = 0; i < 700; i++) await Bun.write(path.join(dir, "apps", `f${String(i).padStart(4, "0")}.txt`), "x") + await Bun.write(path.join(dir, "transform", "dbt_project.yml"), "name: t\n") + }, + (dir) => TS.detectFacts({ directory: dir, home: path.join(dir, "nohome"), memoryPresent: async () => false }), + ) + expect(found.dbtProject).toBe(true) + }) + + test("project facts: parents up to the project boundary, root markers past a large folder, upper-case SQL, usable connections only", async () => { + const facts = (files: Record, pick: (dir: string) => { directory: string; root?: string }) => + inProject( + async (dir) => { + for (const [name, body] of Object.entries(files)) await Bun.write(path.join(dir, name), body) + }, + (dir) => TS.detectFacts({ ...pick(dir), home: path.join(dir, "nohome"), memoryPresent: async () => false }), + ) + // session started in a subfolder of a dbt project + expect(await facts({ "dbt_project.yml": "name: a\n", "models/marts/x.sql": "select 1" }, (d) => ({ directory: path.join(d, "models", "marts"), root: d }))).toMatchObject({ dbtProject: true }) + // a sibling folder that merely shares the boundary's prefix is not inside it, and "/" is no boundary + expect(await facts({ "dbt_project.yml": "a", "sub/x.txt": "x" }, (d) => ({ directory: path.join(d, "sub"), root: path.parse(d).root }))).toMatchObject({ dbtProject: false }) + // but not above the boundary + expect(await facts({ "dbt_project.yml": "name: a\n", "sub/x.txt": "x" }, (d) => ({ directory: path.join(d, "sub"), root: path.join(d, "sub") }))).toMatchObject({ dbtProject: false }) + // a root marker is found even when many earlier-named folders exist + const many: Record = { "zz/dbt_project.yml": "a" } + for (let i = 0; i < 600; i++) many[`a${String(i).padStart(4, "0")}/f.txt`] = "x" + expect(await facts({ ...many, "dbt_project.yml": "name: r\n" }, (d) => ({ directory: d }))).toMatchObject({ dbtProject: true }) + expect(await facts({ "Report.SQL": "select 1" }, (d) => ({ directory: d }))).toMatchObject({ sqlFiles: true }) + // connections: only entries the registry would accept count + const saved = process.env.ALTIMATE_CODE_CONN_PROBE + // Other connection variables in the caller's environment must not leak into the probe. + const others = Object.entries(process.env).filter(([k]) => k.startsWith("ALTIMATE_CODE_CONN_") && k !== "ALTIMATE_CODE_CONN_PROBE") + for (const [k] of others) delete process.env[k] + try { + process.env.ALTIMATE_CODE_CONN_PROBE = "not-json" + expect(await facts({}, (d) => ({ directory: d }))).toMatchObject({ warehouse: false }) + process.env.ALTIMATE_CODE_CONN_PROBE = '{"type":"duckdb","path":"x.db"}' + expect(await facts({}, (d) => ({ directory: d }))).toMatchObject({ warehouse: true }) + delete process.env.ALTIMATE_CODE_CONN_PROBE + expect(await facts({ ".altimate-code/connections.json": '{"wh":{"nothing":1}}' }, (d) => ({ directory: d }))).toMatchObject({ warehouse: false }) + } finally { + if (saved === undefined) delete process.env.ALTIMATE_CODE_CONN_PROBE + else process.env.ALTIMATE_CODE_CONN_PROBE = saved + for (const [k, v] of others) process.env[k] = v + } + }) + + test("a project with no signal gets the setup tools; a dbt project does not hide dbt tools", async () => { + const ids = (await registryIds()).filter((id) => id !== "invalid") + const none = new Set(TS.hiddenIds(ids, TS.groupsFor({ dbtProject: false, sqlFiles: false, warehouse: false, memory: false }))) + expect(none.has("project_scan")).toBe(false) + expect(none.has("warehouse_add")).toBe(false) + expect(none.has("dbt_manifest")).toBe(true) + const dbt = new Set(TS.hiddenIds(ids, TS.groupsFor({ dbtProject: true, sqlFiles: true, warehouse: false, memory: false }))) + expect(dbt.has("dbt_manifest")).toBe(false) + expect(dbt.has("finops_query_history")).toBe(true) // no warehouse: cost analysis is reached through tool_run + const warehouse = new Set(TS.hiddenIds(ids, TS.groupsFor({ dbtProject: false, sqlFiles: false, warehouse: true, memory: false }))) + expect(warehouse.has("finops_query_history")).toBe(false) + }) + + test("switch: default off, environment beats config", () => { + delete process.env[KEY] + expect(TS.smallerToolListEnabled(undefined)).toBe(false) + expect(TS.smallerToolListEnabled(true)).toBe(true) + process.env[KEY] = "0" + expect(TS.smallerToolListEnabled(true)).toBe(false) + process.env[KEY] = "1" + expect(TS.smallerToolListEnabled(undefined)).toBe(true) + }) +}) + +const model = { + id: "claude-test", + providerID: "anthropic", + api: { id: "claude-test", npm: "@ai-sdk/anthropic" }, + capabilities: { toolcall: true }, + options: {}, + variants: {}, + limit: { context: 200000, output: 8000 }, +} as any + +function fakeProcessor() { + return { + message: { id: "msg_test", sessionID: "ses_test" }, + beginToolExecution: () => ({}), + finishToolExecution: () => {}, + partFromToolExecution: () => undefined, + partFromToolCall: () => undefined, + updateToolCall: async () => undefined, + completeToolCall: async () => undefined, + } as any +} + +async function resolve(sessionID: string, agentOverride?: any, sessionPermission: any[] = []) { + return SessionPrompt.resolveTools({ + agent: agentOverride ?? ({ name: "builder", mode: "primary", permission: [], options: {} } as any), + model, + session: { id: sessionID, permission: sessionPermission } as any, + processor: fakeProcessor(), + bypassAgentCheck: false, + messages: [], + }) +} + +async function inProject(setup: (dir: string) => Promise, fn: (dir: string) => Promise): Promise { + await using tmp = await tmpdir({ git: true, init: setup }) + const home = process.env.OPENCODE_TEST_HOME + process.env.OPENCODE_TEST_HOME = tmp.path + try { + return await provideTestInstance({ directory: tmp.path, fn: (ctx) => Instance.restore(ctx, () => fn(tmp.path)) }) + } finally { + if (home === undefined) delete process.env.OPENCODE_TEST_HOME + else process.env.OPENCODE_TEST_HOME = home + } +} + +async function registryIds() { + return inProject(async () => {}, async () => ToolRegistry.ids()) +} + +const dbtProject = async (dir: string) => { + await Bun.write(path.join(dir, "dbt_project.yml"), "name: demo\nprofile: demo\nversion: '1.0'\n") + await Bun.write(path.join(dir, "models", "a.sql"), "select 1 as id\n") +} + +describe("smaller tool list in a session", () => { + beforeEach(() => TS.reset()) + + test("switch off: the full list and no tool_run", async () => { + await inProject(dbtProject, async () => { + process.env[KEY] = "0" + const tools = await resolve("ses_off") + expect(Object.keys(tools)).toContain("finops_query_history") + expect(Object.keys(tools)).not.toContain("tool_run") + }) + }) + + test("switch on: smaller list, identical on every step and in a second session, core present", async () => { + await inProject(dbtProject, async () => { + process.env[KEY] = "0" + const full = Object.keys(await resolve("ses_full")) + process.env[KEY] = "1" + const step1 = Object.keys(await resolve("ses_a")) + const step2 = Object.keys(await resolve("ses_a")) + const step3 = Object.keys(await resolve("ses_a")) + const otherSession = Object.keys(await resolve("ses_b")) + expect(step1.length).toBeLessThan(full.length) + // Same order too: the provider receives tools in record order and the cache keys on it. + expect(step2).toEqual(step1) + expect(step3).toEqual(step1) + expect(otherSession).toEqual(step1) + const bytes = async (id: string) => + JSON.stringify( + Object.entries(await resolve(id)).map(([k, t]) => [k, (t as any).description, (t as any).inputSchema?.jsonSchema ?? null]), + ) + expect(await bytes("ses_c")).toBe(await bytes("ses_d")) + for (const id of ALWAYS.filter((id) => id !== "invalid")) expect(step1).toContain(id) + expect(step1).toContain("invalid") + expect(step1).toContain("dbt_manifest") + expect(step1).not.toContain("finops_query_history") + expect(step1[step1.length - 1]).toBe("tool_run") + }) + }) + + test("the tool definitions sent to the model are byte-identical across steps", async () => { + await inProject(dbtProject, async () => { + process.env[KEY] = "1" + const snapshot = async () => { + const tools = await resolve("ses_bytes") + return JSON.stringify( + Object.entries(tools).map(([id, t]) => [id, (t as any).description, (t as any).inputSchema?.jsonSchema ?? null]), + ) + } + const first = await snapshot() + // Using a hidden tool between steps must not change the definitions. + const tools = await resolve("ses_bytes") + const ran = await (tools.tool_run as any).execute({ name: "altimate_core_import_ddl", arguments: { ddl: "CREATE TABLE t (a INT)" } }, { toolCallId: "c1", messages: [] }) + expect(ran.metadata.tool_run).toBe("altimate_core_import_ddl") + expect(await snapshot()).toBe(first) + }) + }) + + test("the list follows the project: a folder with no signal gets the setup tools, not dbt tools", async () => { + await inProject(async () => {}, async () => { + process.env[KEY] = "1" + const keys = Object.keys(await resolve("ses_none")) + expect(keys).toContain("project_scan") + expect(keys).not.toContain("dbt_manifest") + expect(keys).toContain("tool_run") + }) + }) + + test("the decision is made once: a later change to the project does not change the list", async () => { + await inProject(async () => {}, async (dir) => { + process.env[KEY] = "1" + const before = Object.keys(await resolve("ses_once")) + await dbtProject(dir) + expect(Object.keys(await resolve("ses_once"))).toEqual(before) + // A new session sees the new state. + expect(Object.keys(await resolve("ses_next"))).toContain("dbt_manifest") + }) + }) + + test("a tool outside the list can be run through tool_run, with the same result as calling it", async () => { + await inProject(dbtProject, async () => { + process.env[KEY] = "0" + const direct = await resolve("ses_direct") + const wanted = { ddl: "CREATE TABLE t (a INT, b TEXT)" } + const expected = await (direct.altimate_core_import_ddl as any).execute(wanted, { toolCallId: "c0", messages: [] }) + expect(expected.output).toContain("CREATE TABLE t") // the tool really ran: it is not an error text compared with itself + expect(expected.metadata?.error).toBeUndefined() + + process.env[KEY] = "1" + const tools = await resolve("ses_hidden") + expect(Object.keys(tools)).not.toContain("finops_query_history") + const hidden = TS.hiddenFor(tools.tool_run)! + expect(Object.keys(hidden)).toContain("finops_query_history") + const viaRun = await (tools.tool_run as any).execute({ name: "altimate_core_import_ddl", arguments: wanted }, { toolCallId: "c1", messages: [] }) + expect(viaRun.output).toBe(expected.output) + expect(viaRun.metadata.tool_run).toBe("altimate_core_import_ddl") + }) + }) + + test("tool_run reports an unknown tool and a bad argument usefully, and obeys the agent's permissions", async () => { + await inProject(dbtProject, async () => { + process.env[KEY] = "1" + const tools = await resolve("ses_errs") + const run = (args: any) => (tools.tool_run as any).execute(args, { toolCallId: "c", messages: [] }) + await expect(run({ name: "does_not_exist", arguments: {} })).rejects.toThrow(/No tool named "does_not_exist"/) + await expect(run({ name: "bash", arguments: { command: "echo hi" } })).rejects.toThrow(/called directly/) + await expect(run({ name: "altimate_core_import_ddl", arguments: { nope: 1 } })).rejects.toThrow(/Parameters of altimate_core_import_ddl/) + + // A tool the agent may not use is not reachable through tool_run either. + const denying = { name: "reader", mode: "primary", permission: [{ permission: "altimate_core_import_ddl", pattern: "*", action: "deny" }], options: {} } + const limited = await resolve("ses_denied", denying) + expect(Object.keys(TS.hiddenFor(limited.tool_run)!)).not.toContain("altimate_core_import_ddl") + expect(limited.tool_run!.description).not.toContain("altimate_core_import_ddl") + }) + }) + + test("a direct call to a hidden tool is rerouted instead of failing", async () => { + await inProject(dbtProject, async () => { + process.env[KEY] = "1" + const tools = await resolve("ses_reroute") + const hidden = TS.hiddenFor(tools.tool_run)! + expect(Object.keys(hidden)).toContain("altimate_core_import_ddl") + expect(LLM.rerouteHiddenCall).toBeDefined() + const failed = { toolCall: { toolName: "altimate_core_import_ddl", toolCallId: "call_1", input: JSON.stringify({ ddl: "CREATE TABLE t (a INT)" }) } } + const repaired = LLM.rerouteHiddenCall(tools, failed.toolCall as any)! + expect(repaired.toolName).toBe("tool_run") + expect(JSON.parse(repaired.input)).toEqual({ name: "altimate_core_import_ddl", arguments: { ddl: "CREATE TABLE t (a INT)" } }) + const result = await (tools.tool_run as any).execute(JSON.parse(repaired.input), { toolCallId: "call_1", messages: [] }) + expect(result.metadata.rerouted).toBe(true) + // A name that is neither offered nor reachable is not rerouted. + expect(LLM.rerouteHiddenCall(tools, { toolName: "made_up", toolCallId: "x", input: "{}" } as any)).toBeUndefined() + }) + }) +}) + +describe("tool selection at the edges", () => { + beforeEach(() => TS.reset()) + + test("concurrent first calls for one session share one decision", async () => { + let probes = 0 + const facts = async () => { + probes++ + await new Promise((resolve) => setTimeout(resolve, 5)) + return { dbtProject: probes === 1, sqlFiles: false, warehouse: false, memory: false } + } + const [a, b] = await Promise.all([TS.decide("ses_race", facts), TS.decide("ses_race", facts)]) + expect(probes).toBe(1) + expect(a).toBe(b) + }) + + test("a user's own tool that is named tool_run, or replaces a built-in id, is left alone", async () => { + await inProject( + async (dir) => { + await dbtProject(dir) + const toolDir = path.join(dir, ".opencode", "tool") + await Bun.write(path.join(toolDir, "tool_run.ts"), "export default { description: 'mine', args: {}, execute: async () => 'mine' }\n") + await Bun.write( + path.join(toolDir, "finops_query_history.ts"), + "export default { description: 'my own finops', args: {}, execute: async () => 'mine' }\n", + ) + }, + async () => { + process.env[KEY] = "1" + const tools = await resolve("ses_external") + expect(tools.tool_run!.description).toBe("mine") + expect(tools.finops_query_history!.description).toBe("my own finops") + // Nothing is hidden, since the name tool_run is taken. + expect(Object.keys(tools)).toContain("altimate_core_fingerprint") + }, + ) + }) + + test("an allowlist agent keeps tool_run for the hidden tools it allows, but not the ones it does not", async () => { + await inProject(dbtProject, async () => { + process.env[KEY] = "1" + const allowlist = { + name: "restricted", + mode: "primary", + permission: [ + { permission: "*", pattern: "*", action: "deny" }, + { permission: "read", pattern: "*", action: "allow" }, + { permission: "altimate_core_import_ddl", pattern: "*", action: "allow" }, + ], + options: {}, + } as any + const resolved = await resolve("ses_allowlist", allowlist) + expect(Object.keys(TS.hiddenFor(resolved.tool_run)!)).toEqual(["altimate_core_import_ddl"]) + // The step that sends the request applies the agent's permissions to the list; tool_run must survive. + const sent = await LLM.resolveTools({ tools: { ...resolved }, agent: allowlist, user: {} as any }) + expect(Object.keys(sent)).toContain("tool_run") + expect(Object.keys(sent)).toContain("read") + expect(Object.keys(sent)).not.toContain("bash") + // A rule that names tool_run itself turns it off. + const off = { ...allowlist, permission: [...allowlist.permission, { permission: "tool_run", pattern: "*", action: "deny" }] } + expect(Object.keys(await LLM.resolveTools({ tools: { ...resolved }, agent: off, user: {} as any }))).not.toContain("tool_run") + }) + }) + + test("a user's own tool named tool_run gets no exemption from the agent's permissions", async () => { + await inProject( + async (dir) => { + await dbtProject(dir) + await Bun.write( + path.join(dir, ".opencode", "tool", "tool_run.ts"), + "export default { description: 'mine', args: {}, execute: async () => 'mine' }\n", + ) + }, + async () => { + process.env[KEY] = "1" + const denying = { name: "restricted", mode: "primary", permission: [{ permission: "*", pattern: "*", action: "deny" }], options: {} } as any + const resolved = await resolve("ses_custom_router", denying) + expect(resolved.tool_run!.description).toBe("mine") + expect(Object.keys(await LLM.resolveTools({ tools: { ...resolved }, agent: denying, user: {} as any }))).not.toContain("tool_run") + }, + ) + }) + + test("arguments that are not an object are rejected, not turned into defaults", async () => { + await inProject(dbtProject, async () => { + process.env[KEY] = "1" + const tools = await resolve("ses_nullargs") + const run = (args: any) => (tools.tool_run as any).execute(args, { toolCallId: "n", messages: [] }) + for (const bad of [null, [], 3, '{"ddl":"CREATE TABLE t (a INT)"}']) await expect(run({ name: "altimate_core_import_ddl", arguments: bad })).rejects.toThrow(/must be an object/) + for (const input of ["null", "[]", "5"]) + expect(LLM.rerouteHiddenCall(tools, { toolName: "altimate_core_import_ddl", toolCallId: "n", input } as any)).toBeUndefined() + }) + }) + + test("resolveTools keeps a hidden-by-project tool that the agent's own prompt names", async () => { + await inProject(dbtProject, async () => { + process.env[KEY] = "1" + const plain = await resolve("ses_prompt_a") + expect(Object.keys(plain)).not.toContain("finops_query_history") + const named = { name: "analyst", mode: "primary", permission: [], options: {}, prompt: "Use finops_query_history to review spend." } as any + const tools = await resolve("ses_prompt_b", named) + expect(Object.keys(tools)).toContain("finops_query_history") + expect(Object.keys(TS.hiddenFor(tools.tool_run)!)).not.toContain("finops_query_history") + }) + }) + + test("a wildcard that names tool_run turns it off; only the catch-all is overridden", async () => { + await inProject(dbtProject, async () => { + process.env[KEY] = "1" + const allow = [{ permission: "altimate_core_import_ddl", pattern: "*", action: "allow" }] + const make = (rules: any[]) => ({ name: "restricted", mode: "primary", permission: rules, options: {} }) as any + for (const [rules, kept] of [ + [[{ permission: "*", pattern: "*", action: "deny" }, ...allow], true], + [[{ permission: "*", pattern: "*", action: "deny" }, { permission: "tool_*", pattern: "*", action: "deny" }, ...allow], false], + [[{ permission: "*", pattern: "*", action: "deny" }, ...allow, { permission: "tool_run", pattern: "*", action: "deny" }], false], + ] as const) { + const agent = make(rules as any) + const resolved = await resolve(`ses_wild_${Math.random()}`, agent) + const sent = await LLM.resolveTools({ tools: { ...resolved }, agent, user: {} as any }) + expect(Object.keys(sent).includes("tool_run")).toBe(kept) + } + }) + }) + + test("a session's decision is dropped when the session is deleted, and kept while it exists", async () => { + await inProject(async () => {}, async (dir) => { + process.env[KEY] = "1" + const before = Object.keys(await resolve("ses_del")) + await dbtProject(dir) + expect(Object.keys(await resolve("ses_del"))).toEqual(before) // still alive: the decision stands + const { Bus } = await import("../../src/bus") + const { Session } = await import("../../src/session") + await Bus.publish(Session.Event.Deleted, { info: { id: "ses_del" } as any }) + expect(Object.keys(await resolve("ses_del"))).toContain("dbt_manifest") // new facts are read again + }) + }) + + test("a wrongly cased direct call to a hidden tool is rerouted like an offered tool's would be", async () => { + await inProject(dbtProject, async () => { + process.env[KEY] = "1" + const tools = await resolve("ses_case") + const repaired = LLM.rerouteHiddenCall(tools, { toolName: "ALTIMATE_CORE_IMPORT_DDL", toolCallId: "k", input: '{"ddl":"CREATE TABLE t (a INT)"}' } as any) + expect(repaired && JSON.parse(repaired.input).name).toBe("altimate_core_import_ddl") + }) + }) + + test("router rules: only a blanket deny is overridden; with the router denied, allowed targets stay direct", async () => { + expect(TS.routerAllowed([{ permission: "*", pattern: "*", action: "deny" }])).toBe(true) + expect(TS.routerAllowed([{ permission: "*", pattern: "*", action: "deny" }, { permission: "tool_*", pattern: "*", action: "deny" }])).toBe(false) + expect(TS.routerAllowed([{ permission: "tool_run", pattern: "*", action: "deny" }, { permission: "tool_run", pattern: "*", action: "allow" }])).toBe(true) + // the last matching rule decides: a later catch-all allow wins over an earlier specific deny + expect(TS.routerAllowed([{ permission: "tool_run", pattern: "*", action: "deny" }, { permission: "*", pattern: "*", action: "allow" }])).toBe(true) + // an explicit deny is not undone by a later catch-all + expect(TS.routerAllowed([{ permission: "tool_run", pattern: "*", action: "deny" }, { permission: "*", pattern: "*", action: "deny" }, { permission: "x", pattern: "*", action: "allow" }])).toBe(false) + await inProject(dbtProject, async () => { + process.env[KEY] = "1" + const agent = { name: "r", mode: "primary", options: {}, permission: [{ permission: "tool_run", pattern: "*", action: "deny" }] } as any + const keys = Object.keys(await resolve("ses_router_off", agent)) + expect(keys).not.toContain("tool_run") + expect(keys).toContain("finops_query_history") // nothing was hidden + }) + }) + + test("Kilo's rule order: a later patterned catch-all allow does not lift an explicit router deny", async () => { + const rules = [ + { permission: "*", pattern: "*", action: "allow" }, + { permission: "tool_run", pattern: "*", action: "deny" }, + { permission: "*", pattern: "safe", action: "allow" }, + ] + expect(TS.routerAllowed(rules)).toBe(false) + await inProject(dbtProject, async () => { + process.env[KEY] = "1" + const agent = { name: "k", mode: "primary", options: {}, permission: rules } as any + const keys = Object.keys(await resolve("ses_kilo1", agent)) + expect(keys).not.toContain("tool_run") + expect(keys).toContain("altimate_core_import_ddl") // nothing hidden, so nothing needs the router + }) + }) + + test("Kilo's rule order for a target: an explicit deny is enforced when it is called, through the router and directly", async () => { + const rules = [ + { permission: "*", pattern: "*", action: "allow" }, + { permission: "altimate_core_import_ddl", pattern: "*", action: "deny" }, + { permission: "*", pattern: "safe", action: "allow" }, + ] + const call = { ddl: "CREATE TABLE t (a INT)" } + const ctx = { toolCallId: "d", messages: [] } + await inProject(dbtProject, async () => { + // The list filter, which looks only at the last rule that names the tool, does not see this deny. + const { PermissionNext } = await import("../../src/permission/next") + expect(PermissionNext.disabled(["altimate_core_import_ddl"], rules as any).size).toBe(0) + expect(PermissionNext.evaluate("altimate_core_import_ddl", "*", rules as any).action).toBe("deny") + + const agent = { name: "k", mode: "primary", options: {}, permission: rules } as any + process.env[KEY] = "1" + const routed = await resolve("ses_kilo2", agent) + expect(Object.keys(TS.hiddenFor(routed.tool_run)!)).toContain("altimate_core_import_ddl") + await expect((routed.tool_run as any).execute({ name: "altimate_core_import_ddl", arguments: call }, ctx)).rejects.toThrow(/deny/) + + // the same deny, held through the session's rules, for a tool that stays in the direct list + const direct = await resolve("ses_kilo3", { name: "k2", mode: "primary", options: {}, permission: [{ permission: "tool_run", pattern: "*", action: "deny" }] } as any, [ + { permission: "altimate_core_import_ddl", pattern: "*", action: "deny" }, + { permission: "*", pattern: "safe", action: "allow" }, + ]) + expect(Object.keys(direct)).toContain("altimate_core_import_ddl") + await expect((direct.altimate_core_import_ddl as any).execute(call, ctx)).rejects.toThrow(/rule/) + + // with the switch off nothing changes: the call is made as it always was + process.env[KEY] = "0" + const off = await resolve("ses_kilo4", { name: "k3", mode: "primary", options: {}, permission: [] } as any, [ + { permission: "altimate_core_import_ddl", pattern: "*", action: "deny" }, + { permission: "*", pattern: "safe", action: "allow" }, + ]) + const out = await (off.altimate_core_import_ddl as any).execute(call, ctx) + expect(out.output).toContain("CREATE TABLE") + }) + }) + + test("a target the session's rules deny is not reachable through the router", async () => { + await inProject(dbtProject, async () => { + process.env[KEY] = "1" + const tools = await resolve("ses_sessiondeny", undefined, [{ permission: "altimate_core_import_ddl", pattern: "*", action: "deny" }]) + expect(Object.keys(TS.hiddenFor(tools.tool_run)!)).not.toContain("altimate_core_import_ddl") + expect(Object.keys(TS.hiddenFor(tools.tool_run)!)).toContain("altimate_core_fingerprint") + }) + }) + + test("a name the request already offers is not rerouted to a similarly spelled hidden tool", async () => { + await inProject(dbtProject, async () => { + process.env[KEY] = "1" + const tools = await resolve("ses_offered_name") + ;(tools as any).ALTIMATE_CORE_IMPORT_DDL = { description: "a user tool" } + expect(LLM.rerouteHiddenCall(tools, { toolName: "ALTIMATE_CORE_IMPORT_DDL", toolCallId: "o", input: "{}" } as any)).toBeUndefined() + }) + }) + + test("empty or non-object input is not rerouted", async () => { + await inProject(dbtProject, async () => { + process.env[KEY] = "1" + const tools = await resolve("ses_emptyinput") + for (const input of ["", "null", "[]", "5", "{"]) + expect(LLM.rerouteHiddenCall(tools, { toolName: "altimate_core_import_ddl", toolCallId: "e", input } as any)).toBeUndefined() + }) + }) + + test("a malformed direct call is not rerouted with default arguments", async () => { + await inProject(dbtProject, async () => { + process.env[KEY] = "1" + const tools = await resolve("ses_malformed") + const call = { toolName: "altimate_core_import_ddl", toolCallId: "m1", input: '{"ddl":"CREATE TABLE t (a INT)"' } + expect(LLM.rerouteHiddenCall(tools, call as any)).toBeUndefined() + }) + }) + + test("a historical entry for a hidden tool runs that tool instead of answering that it is gone", async () => { + await inProject(dbtProject, async () => { + process.env[KEY] = "1" + const tools = await resolve("ses_history") + LLM.addHistoricalToolStubs(tools as any, ["altimate_core_import_ddl"], "auto", TS.hiddenFor(tools.tool_run)) + const out = await (tools.altimate_core_import_ddl as any).execute({ ddl: "CREATE TABLE t (a INT)" }, { toolCallId: "h1", messages: [] }) + expect(out.output).not.toContain("no longer available") + expect(tools.altimate_core_import_ddl!.description).toContain("tool_run") + }) + }) +}) diff --git a/packages/opencode/test/session/system.test.ts b/packages/opencode/test/session/system.test.ts index ee627c7c3..a9cd94e61 100644 --- a/packages/opencode/test/session/system.test.ts +++ b/packages/opencode/test/session/system.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, spyOn } from "bun:test" +import { afterAll, beforeAll, describe, expect, spyOn } from "bun:test" import { Effect } from "effect" import type { Agent } from "../../src/agent/agent" import { NamedError } from "@opencode-ai/core/util/error" @@ -63,6 +63,17 @@ const build: Agent.Info = { const it = withLegacyInstanceRunner(testEffect(SystemPrompt.layer)) +// These tests pin the full (unbounded) skill listing; the bounded format has its own tests in +// test/altimate/skill-listing.test.ts. +const ORIGINAL_BOUNDED = process.env.ALTIMATE_BOUNDED_SKILL_LISTING +beforeAll(() => { + process.env.ALTIMATE_BOUNDED_SKILL_LISTING = "0" +}) +afterAll(() => { + if (ORIGINAL_BOUNDED === undefined) delete process.env.ALTIMATE_BOUNDED_SKILL_LISTING + else process.env.ALTIMATE_BOUNDED_SKILL_LISTING = ORIGINAL_BOUNDED +}) + describe("session.system", () => { it.instance( "skills output is sorted by name and stable across calls", diff --git a/packages/opencode/test/tool/skill.test.ts b/packages/opencode/test/tool/skill.test.ts index bf41a9a39..fc609a381 100644 --- a/packages/opencode/test/tool/skill.test.ts +++ b/packages/opencode/test/tool/skill.test.ts @@ -1,5 +1,5 @@ // altimate_change start - add imports for env fingerprint skill selection tests -import { afterEach, describe, expect, test } from "bun:test" +import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test" // altimate_change end import path from "path" import { pathToFileURL } from "url" @@ -51,6 +51,17 @@ function seedCache(skillNames: string[]) { } // altimate_change end +// These tests pin the full (unbounded) skill listing; the bounded format has its own tests in +// test/altimate/skill-listing.test.ts. +const ORIGINAL_BOUNDED = process.env.ALTIMATE_BOUNDED_SKILL_LISTING +beforeAll(() => { + process.env.ALTIMATE_BOUNDED_SKILL_LISTING = "0" +}) +afterAll(() => { + if (ORIGINAL_BOUNDED === undefined) delete process.env.ALTIMATE_BOUNDED_SKILL_LISTING + else process.env.ALTIMATE_BOUNDED_SKILL_LISTING = ORIGINAL_BOUNDED +}) + describe("tool.skill", () => { // altimate_change start - reset skill selector and fingerprint caches between tests afterEach(() => {