diff --git a/docs/docs/configure/agents.md b/docs/docs/configure/agents.md index e55b63bb16..a45858470c 100644 --- a/docs/docs/configure/agents.md +++ b/docs/docs/configure/agents.md @@ -28,6 +28,7 @@ The agent for asking questions about your data. Truly read-only mode for safe da - **Bash**: Safe commands auto-allowed (`ls`, `grep`, `cat`, `head`, `tail`, `find`, `wc`). dbt read commands allowed (`dbt list`, `dbt ls`, `dbt debug`). Everything else denied. - **Web**: Fetch and search allowed without prompts - **Schema/warehouse/finops**: All inspection tools available +- **Workspace knowledge**: Loads workspace and project skills; reads workspace memory and views this checkout's memory audit log. Reloading memory syncs the local copy with the workspace, which can remove a local copy of a block the workspace archived. Saving memory is done with `builder`. !!! tip Use `analyst` when exploring data to ensure no accidental writes. Switch to `builder` when you're ready to create or modify models. diff --git a/docs/docs/data-engineering/agent-modes.md b/docs/docs/data-engineering/agent-modes.md index 6e8e8333b6..2c8b0c3fc8 100644 --- a/docs/docs/data-engineering/agent-modes.md +++ b/docs/docs/data-engineering/agent-modes.md @@ -134,6 +134,7 @@ Estimated savings: ~60 credits/week (71% reduction on this query alone) - Search warehouse metadata - Detect PII columns - Save training entries +- Read and reload workspace memory, view this checkout's memory audit log, and answer questions about team conventions from workspace skills (saving memory is done with builder) --- diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index feea76341b..91e413e8ac 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -334,6 +334,13 @@ export const layer = Layer.effect( question: "allow", // Skills return instruction text only (no side effects), so they are safe for a read-only agent. skill: "allow", + // Workspace memory: read, reload and view the audit log. Reloading syncs like the + // load every session runs (it can drop a local copy of a block the workspace has + // archived), but cannot create or edit memory. Saving and deleting stay denied, so a + // decision is recorded through builder. + altimate_memory_read: "allow", + altimate_memory_refresh: "allow", + altimate_memory_audit: "allow", // Delegation is limited to the read-only data-explorer subagent: general/explore/build // carry broader permissions than analyst, and children do not inherit parent-agent limits. task: { diff --git a/packages/opencode/src/altimate/prompts/analyst.txt b/packages/opencode/src/altimate/prompts/analyst.txt index f121f71744..f0460ec7e9 100644 --- a/packages/opencode/src/altimate/prompts/analyst.txt +++ b/packages/opencode/src/altimate/prompts/analyst.txt @@ -10,7 +10,10 @@ Treat inferred text labels as data: fixed rubric, stable IDs, coverage and dupli Use selected columns, partition filters and LIMIT/sampling to inspect cheaply; estimates are not exact final computations. Read JSONL spill paths with `read`; spills contain returned rows, not necessarily all matches. Aggregate in SQL for exact answers. On timeout, revise the query. Report costs only from evidence. ## Skills -Use `skill` when available: /data-question, /cost-report, /query-optimize, /sql-translate, /dbt-analyze, /lineage-diff, /data-viz. File-writing steps require the builder agent. +Use `skill` when available. When the skill list in your system prompt has one matching the request, load it, following the list's source precedence; workspace and project skills are included. Built-in data skills include /data-question, /cost-report, /query-optimize, /sql-translate, /dbt-analyze, /lineage-diff and /data-viz. File-writing steps require the builder agent. + +## Workspace knowledge +A question about the team's conventions, standards or past decisions is answered from workspace skills and memory, not from warehouse data: load the matching skill, check `altimate_memory_read` when it is in your tool list, and reply in plain text that states what was found or that nothing was found. Such a reply is not a data value, so do not finish it with `answer_commit`. You cannot save workspace memory blocks; when asked to save a team decision or convention, say that the builder agent can. A correction to your own behavior is still saved with `training_save`, as below. ## Teammate Training You are a trainable AI teammate. Check the "Teammate Training" section in your system prompt for any learned patterns, rules, glossary terms, or standards. Always apply learned training when relevant. @@ -20,4 +23,4 @@ Use `training_list` to review learned knowledge. Skills: /teach, /train, /traini When the request supplies an output JSON schema (the `StructuredOutput` tool is available), finish with `StructuredOutput` instead of `answer_commit` or a plain-text reply; follow that schema. The text finalization rules below apply when no output schema is supplied. -Finish by calling `answer_commit` with the single chosen value(s) with one field per item the request asks for (every named item, such as an entity and its related item or count, must be its own field: when the request asks for only the answer, anything left in the basis or prose is dropped), a one-or-two-sentence basis (population, grain, source object/column), or `unresolved` with the reason, then reply with exactly the text it returns and nothing else. Always supply `basis`, even when unresolved; it is never shown when the request asks for only the answer. Never list excluded candidates or alternative answers; a top-N with a tie at the boundary still returns N concrete rows (note the tie briefly in `rows.note`); when the request asks for only the answer or a specific format, set `answer_only`. If `answer_commit` is unavailable, finish with one or two sentences of basis and a final line `**Answer:** `, or `**Answer:** unable to determine — `; when the request asks for only the answer or a specific format, the final message is exactly the requested value(s) or table in the requested format, with no label and no basis (or `unable to determine — ` if unresolved); never guess. +For a data question, finish by calling `answer_commit` with the single chosen value(s) with one field per item the request asks for (every named item, such as an entity and its related item or count, must be its own field: when the request asks for only the answer, anything left in the basis or prose is dropped), a one-or-two-sentence basis (population, grain, source object/column), or `unresolved` with the reason, then reply with exactly the text it returns and nothing else. Always supply `basis`, even when unresolved; it is never shown when the request asks for only the answer. Never list excluded candidates or alternative answers; a top-N with a tie at the boundary still returns N concrete rows (note the tie briefly in `rows.note`); when the request asks for only the answer or a specific format, set `answer_only`. If `answer_commit` is unavailable, finish with one or two sentences of basis and a final line `**Answer:** `, or `**Answer:** unable to determine — `; when the request asks for only the answer or a specific format, the final message is exactly the requested value(s) or table in the requested format, with no label and no basis (or `unable to determine — ` if unresolved); never guess. diff --git a/packages/opencode/src/altimate/workspace/identity.ts b/packages/opencode/src/altimate/workspace/identity.ts index 2ad6f9f1e2..0490cbc40c 100644 --- a/packages/opencode/src/altimate/workspace/identity.ts +++ b/packages/opencode/src/altimate/workspace/identity.ts @@ -85,6 +85,9 @@ export type RenderOptions = { * which store is the team's. `systemSection` derives it from the enablement memo; * the pure formatter takes it as an argument so tests stay deterministic. */ teamMemory?: boolean + /** Which memory tools the current agent may call. Defaults to both: the line names + * `altimate_memory_write`, and an agent that cannot call it must not be told to. */ + memoryTools?: MemoryTools /** For `unknown`: no Altimate account is configured, so nothing could be checked. */ noAccount?: boolean /** For `unknown`: an IDE pin governs this process, so the extension's selection is the thing to check. */ @@ -94,12 +97,31 @@ export type RenderOptions = { /** Two stores answer "remember this". Only one is read by other linked checkouts, * and nothing told the model which — so on a plain "save this for the team" it * reached for the engine's hub and the decision never left the session (#1332). */ -const TEAM_MEMORY_LINE = +const TEAM_MEMORY_SAVE_LINE = "Team memory: save decisions and conventions with `altimate_memory_write` (scope " + '"project" for this project, "global" for everything); they sync to the workspace and to every ' + "linked checkout. The `datamate_*` memory tools (`datamate_add_memories`, `datamate_search_memory`) " + - "are the engine's separate store and are not what teammates' sessions read. Before saving, check " + - "`altimate_memory_read` for an existing block on the same subject and update it rather than add a duplicate." + "are the engine's separate store and are not what teammates' sessions read." + +/** Appended only when the agent can also read, since it names the read tool. */ +const TEAM_MEMORY_DEDUP_HINT = + " Before saving, check `altimate_memory_read` for an existing block on the same subject and update " + + "it rather than add a duplicate." + +/** For an agent that can read memory but not save it (analyst): point it at the read tool, + * and say where saving happens instead of naming a tool it does not have. */ +const TEAM_MEMORY_READ_ONLY_LINE = + "Team memory: look up the workspace's saved decisions and conventions with `altimate_memory_read`. " + + "This agent cannot save workspace memory blocks; when asked to save a decision or " + + "convention for the team, say that an agent with memory write access, such as builder, can." + +export type MemoryTools = { read: boolean; write: boolean } + +function teamMemoryLine(tools: MemoryTools = { read: true, write: true }): string | undefined { + if (tools.write) return tools.read ? TEAM_MEMORY_SAVE_LINE + TEAM_MEMORY_DEDUP_HINT : TEAM_MEMORY_SAVE_LINE + if (tools.read) return TEAM_MEMORY_READ_ONLY_LINE + return undefined +} function renderBody(outcome: BindingOutcome, opts: RenderOptions = {}): string { if (outcome.status === "bound") { @@ -117,6 +139,7 @@ function renderBody(outcome: BindingOutcome, opts: RenderOptions = {}): string { ? "This session was last known to be pinned by the IDE extension to" : "This project was last known to be linked to" const verifyNoun = pinned ? "pin" : "link" + const memoryLine = opts.teamMemory ? teamMemoryLine(opts.memoryTools) : undefined return [ HEADING, "", @@ -128,7 +151,7 @@ function renderBody(outcome: BindingOutcome, opts: RenderOptions = {}): string { ? " Skills and memory follow this workspace, and so does warehouse tool routing unless " + "integrations are set to local, even where the project's own link names a different one." : ""), - ...(opts.teamMemory ? [TEAM_MEMORY_LINE] : []), + ...(memoryLine ? [memoryLine] : []), `When ${TRIGGER}, the answer is this Altimate Workspace — never substitute ` + "another service's own \"workspace\" (a Databricks workspace, an IDE's " + "workspace folder, etc.) for it, and the reverse: a question about another " + @@ -342,13 +365,13 @@ function governingPin(directory: string): boolean { return pin.kind === "valid" && resolveWithinRoot(directory, pin.root) !== null } -function renderOptions(outcome: BindingOutcome, directory: string): RenderOptions { +function renderOptions(outcome: BindingOutcome, directory: string, memoryTools?: MemoryTools): RenderOptions { if (outcome.status === "unknown") return { pinned: governingPin(directory) } if (outcome.status !== "bound") return {} // A stale outcome is "last known … may since have changed": promising that a // save syncs to that workspace would contradict the line above it. if (outcome.stale) return {} - return { teamMemory: memoryEnabledCached(outcome.binding) === "enabled" } + return { teamMemory: memoryEnabledCached(outcome.binding) === "enabled", memoryTools } } function keyFor(scope: AccountScope, directory: string): string { @@ -415,7 +438,7 @@ async function withContents(outcome: BindingOutcome, directory: string, identity return contents ? `${identity}\n\n${contents}` : identity } -export async function systemSection(): Promise { +export async function systemSection(opts: { memoryTools?: MemoryTools } = {}): Promise { // Behind the same opt-in as everything else about workspaces. A user outside // the pilot has no Altimate Workspace to be linked to, and must not be told // every turn that none is linked and how to link one. @@ -443,7 +466,7 @@ export async function systemSection(): Promise { }) const key = keyFor(scope, directory) const hit = memo.get(key) - if (fresh(hit)) return withContents(hit!.outcome, directory, render(hit!.outcome, MAX_SECTION_CHARS, renderOptions(hit!.outcome, directory))) + if (fresh(hit)) return withContents(hit!.outcome, directory, render(hit!.outcome, MAX_SECTION_CHARS, renderOptions(hit!.outcome, directory, opts.memoryTools))) // The fallback is itself raced against a small budget, so the wait is // bounded by RESOLVE_DEADLINE_MS + FALLBACK_BUDGET_MS, not by the disk. const deadline = after(RESOLVE_DEADLINE_MS, () => @@ -451,7 +474,7 @@ export async function systemSection(): Promise { ) try { const outcome = await Promise.race([resolve(key, directory), deadline]) - return withContents(outcome, directory, render(outcome, MAX_SECTION_CHARS, renderOptions(outcome, directory))) + return withContents(outcome, directory, render(outcome, MAX_SECTION_CHARS, renderOptions(outcome, directory, opts.memoryTools))) } finally { for (const t of timers) clearTimeout(t) } diff --git a/packages/opencode/src/memory/tools/memory-read.ts b/packages/opencode/src/memory/tools/memory-read.ts index f87eb5d8a9..f0ca8dd307 100644 --- a/packages/opencode/src/memory/tools/memory-read.ts +++ b/packages/opencode/src/memory/tools/memory-read.ts @@ -107,7 +107,9 @@ export const MemoryReadTool = Tool.define("altimate_memory_read", { return { title: "Memory: empty", metadata: { count: 0 }, - output: "No memory blocks found. Use altimate_memory_write to save information for future sessions.", + // Read-only agents (analyst) call this tool too, so the result must not + // direct them to a save tool they cannot call. + output: "No memory blocks found.", } } diff --git a/packages/opencode/src/memory/tools/memory-refresh.ts b/packages/opencode/src/memory/tools/memory-refresh.ts index 7c6bdf52bd..c1f974e5dc 100644 --- a/packages/opencode/src/memory/tools/memory-refresh.ts +++ b/packages/opencode/src/memory/tools/memory-refresh.ts @@ -20,7 +20,8 @@ export const MemoryRefreshTool = Tool.define("altimate_memory_refresh", { "workspace memory once at start; anything written after that is invisible", "until this runs.", "", - "Does nothing when the project is not linked to a workspace.", + "Does nothing when the project is not linked to a workspace. May remove local", + "copies of blocks the workspace has archived; never creates or edits memory.", ].join("\n"), parameters: z.object({}), async execute(_args, ctx) { diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 846c2e8ea2..1bf82c7e32 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -473,15 +473,18 @@ export namespace LLM { }) } + // altimate_change start — one removal rule shared with prompt text that names tools + // Tools removed before the model call: denied by the agent, or switched off on the request. + export function removedTools(names: string[], input: Pick): Set { + const disabled = PermissionNext.disabled(names, input.agent.permission) + return new Set(names.filter((tool) => input.user.tools?.[tool] === false || disabled.has(tool))) + } + async function resolveTools(input: Pick) { - const disabled = PermissionNext.disabled(Object.keys(input.tools), input.agent.permission) - for (const tool of Object.keys(input.tools)) { - if (input.user.tools?.[tool] === false || disabled.has(tool)) { - delete input.tools[tool] - } - } + for (const tool of removedTools(Object.keys(input.tools), input)) delete input.tools[tool] return input.tools } + // 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 diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 1c137dc78d..eca2d3c749 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1913,7 +1913,20 @@ export namespace SessionPrompt { // pure link identity. `systemSection()` reads `Instance.directory` itself, inside // its own try/catch — NOT passed as an argument here — so a missing instance // context can't throw synchronously at this call site. - const workspaceIdentity = await WorkspaceIdentity.systemSection() + // The team-memory line names memory tools; describe only the ones this agent and request + // keep. Steps that send no tools at all (answer echo, budget finalization) withhold every + // tool this prompt names, not just these, and are handled on their own paths. + const memoryRemoved = LLM.removedTools(["altimate_memory_read", "altimate_memory_write"], { + agent, + user: lastUser, + }) + const memoryCallable = (name: string) => name in tools && !memoryRemoved.has(name) + const workspaceIdentity = await WorkspaceIdentity.systemSection({ + memoryTools: { + read: memoryCallable("altimate_memory_read"), + write: memoryCallable("altimate_memory_write"), + }, + }) // altimate_change end // altimate_change start — workspace tool awareness. // Reads the snapshot `Precedence.refresh` stored for this turn during tool diff --git a/packages/opencode/test/agent/analyst-data-explorer.test.ts b/packages/opencode/test/agent/analyst-data-explorer.test.ts index b91db472c2..72babad45d 100644 --- a/packages/opencode/test/agent/analyst-data-explorer.test.ts +++ b/packages/opencode/test/agent/analyst-data-explorer.test.ts @@ -75,6 +75,25 @@ it.instance("analyst can load skills", () => }), ) +it.instance("analyst can read workspace memory but not save, extract or delete it", () => + Effect.gen(function* () { + const analyst = (yield* Agent.use.get("analyst"))! + const builder = (yield* Agent.use.get("builder"))! + const de = (yield* Agent.use.get("data-explorer"))! + const read = ["altimate_memory_read", "altimate_memory_refresh", "altimate_memory_audit"] + const write = ["altimate_memory_write", "altimate_memory_delete", "altimate_memory_extract"] + for (const tool of read) expect(action(analyst, tool)).toBe("allow") + for (const tool of write) expect(action(analyst, tool)).toBe("deny") + expect(Permission.disabled([...read, ...write], analyst.permission)).toEqual(new Set(write)) + // The data-explorer subagent stays limited to warehouse and file reads. + for (const tool of [...read, ...write]) expect(action(de, tool)).toBe("deny") + for (const tool of ["altimate_memory_read", "altimate_memory_write"]) expect(action(builder, tool)).toBe("allow") + // Reading memory does not loosen the rest of analyst's read-only policy. + expect(action(analyst, "bash")).toBe("deny") + expect(action(analyst, "sql_execute_write")).toBe("deny") + }), +) + it.instance("analyst may delegate only to data-explorer", () => Effect.gen(function* () { const analyst = yield* Agent.use.get("analyst") diff --git a/packages/opencode/test/altimate/sql-validation-e2e.test.ts b/packages/opencode/test/altimate/sql-validation-e2e.test.ts index ae8c1d3ed2..83e867dbd2 100644 --- a/packages/opencode/test/altimate/sql-validation-e2e.test.ts +++ b/packages/opencode/test/altimate/sql-validation-e2e.test.ts @@ -109,10 +109,11 @@ describe("Tool name consistency in prompts", () => { const paragraph = prompt.trim().split("\n\n").at(-1) // altimate_change start - require one field for each requested answer item expect(paragraph).toContain( - "Finish by calling `answer_commit` with the single chosen value(s) with one field per item the request asks for (every named item, such as an entity and its related item or count, must be its own field: when the request asks for only the answer, anything left in the basis or prose is dropped), a one-or-two-sentence basis (population, grain, source object/column), or `unresolved` with the reason, then reply with exactly the text it returns and nothing else.", + "For a data question, finish by calling `answer_commit` with the single chosen value(s) with one field per item the request asks for (every named item, such as an entity and its related item or count, must be its own field: when the request asks for only the answer, anything left in the basis or prose is dropped), a one-or-two-sentence basis (population, grain, source object/column), or `unresolved` with the reason, then reply with exactly the text it returns and nothing else.", ) // altimate_change end expect(paragraph).toContain("Always supply `basis`, even when unresolved") + expect(prompt).toContain("Such a reply is not a data value, so do not finish it with `answer_commit`.") expect(paragraph).toContain("it is never shown when the request asks for only the answer") expect(paragraph).toContain("Never list excluded candidates or alternative answers") expect(paragraph).toContain("when the request asks for only the answer or a specific format, set `answer_only`") diff --git a/packages/opencode/test/altimate/workspace/identity-section.test.ts b/packages/opencode/test/altimate/workspace/identity-section.test.ts index c4d9c0a7f0..12abb065bb 100644 --- a/packages/opencode/test/altimate/workspace/identity-section.test.ts +++ b/packages/opencode/test/altimate/workspace/identity-section.test.ts @@ -157,6 +157,19 @@ describe("systemSection", () => { memoryEnabledCache.set(77, { checkedAt: Date.now() }) try { expect(await inProject(systemSection)).toContain("Team memory:") + // A read-only agent is told to read, not to save; resolved and memoised outcomes alike. + const readOnly = () => systemSection({ memoryTools: { read: true, write: false } }) + resetOutcomeMemoForTests() + for (let i = 0; i < 2; i++) { + const out = await inProject(readOnly) + expect(out).toContain("Team memory: look up the workspace's saved decisions") + expect(out).not.toContain("altimate_memory_write") + } + // The memo holds the outcome, not the text: the same cached outcome renders per agent. + expect(await inProject(systemSection)).toContain("Team memory: save decisions") + expect(await inProject(() => systemSection({ memoryTools: { read: false, write: false } }))).not.toContain( + "Team memory:", + ) resetOutcomeMemoForTests() resetEnablementMemoForTests() // A remembered "no" from the workspace switches the line off. diff --git a/packages/opencode/test/altimate/workspace/identity.test.ts b/packages/opencode/test/altimate/workspace/identity.test.ts index 68b66cbab8..37a31dccb1 100644 --- a/packages/opencode/test/altimate/workspace/identity.test.ts +++ b/packages/opencode/test/altimate/workspace/identity.test.ts @@ -39,6 +39,41 @@ describe("bound — a specific Altimate Workspace is linked", () => { expect(render({ status: "unbound" }, undefined, { teamMemory: true })).not.toContain("Team memory") }) + const memoryAccessCases: { read: boolean; write: boolean; line?: string; names: string[] }[] = [ + { read: true, write: true, line: "Team memory: save decisions", names: ["altimate_memory_write", "altimate_memory_read"] }, + { read: true, write: false, line: "Team memory: look up the workspace's saved decisions", names: ["altimate_memory_read"] }, + { read: false, write: true, line: "Team memory: save decisions", names: ["altimate_memory_write"] }, + { read: false, write: false, names: [] }, + ] + for (const access of memoryAccessCases) { + test(`the team memory line names only the memory tools the agent can call (read=${access.read}, write=${access.write})`, () => { + const out = render(boundOutcome, undefined, { + teamMemory: true, + memoryTools: { read: access.read, write: access.write }, + }) + const memoryLines = out.split("\n").filter((line) => line.startsWith("Team memory:")) + expect(memoryLines).toHaveLength(access.line ? 1 : 0) + if (access.line) expect(memoryLines[0]).toStartWith(access.line) + for (const tool of ["altimate_memory_read", "altimate_memory_write"]) { + expect(out.includes(tool)).toBe(access.names.includes(tool)) + } + }) + } + + test("the team memory line keeps its text for an agent with full access, and tells a read-only one who saves", () => { + const full = render(boundOutcome, undefined, { teamMemory: true, memoryTools: { read: true, write: true } }) + expect(full).toBe(render(boundOutcome, undefined, { teamMemory: true })) + const readOnly = render(boundOutcome, undefined, { teamMemory: true, memoryTools: { read: true, write: false } }) + expect(readOnly).toContain( + "This agent cannot save workspace memory blocks; when asked to save a decision or " + + "convention for the team, say that an agent with memory write access, such as builder, can.", + ) + // Workspace memory off still wins over the agent's access. + expect(render(boundOutcome, undefined, { teamMemory: false, memoryTools: { read: true, write: false } })).not.toContain( + "Team memory", + ) + }) + test("names the workspace and forbids substituting another service's 'workspace' for an identity question", () => { expect(boundOut).toContain("## Altimate Workspace") expect(boundOut).toContain('"Foo Corp Data Team"') diff --git a/packages/opencode/test/memory/overlay-merge.test.ts b/packages/opencode/test/memory/overlay-merge.test.ts index dddceea31f..93ecae5eae 100644 --- a/packages/opencode/test/memory/overlay-merge.test.ts +++ b/packages/opencode/test/memory/overlay-merge.test.ts @@ -252,6 +252,16 @@ describe("the memory read tool", () => { expect(out).toContain("A LOCAL FACT") expect(out).toContain("A WORKSPACE FACT") }) + + test("an empty result does not tell a read-only agent to save", async () => { + // analyst may read memory but not write it; naming the write tool in the + // result steers it toward a call it cannot make. + await hydrate(SES) + const tool = await initTool(MemoryReadTool) + const res: any = await tool.execute({ scope: "all", tags: ["no-such-tag"] }, { sessionID: SES, agent: "analyst" }) + expect(res.metadata.count).toBe(0) + expect(String(res.output)).toEqual("No memory blocks found.") + }) }) describe("on-demand reload", () => { diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 76faede77f..8af231fc3c 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -2,6 +2,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:tes import path from "path" import { jsonSchema, tool, type ModelMessage, type Tool } from "ai" import { LLM } from "../../src/session/llm" +import { PermissionNext } from "../../src/permission/next" import { Instance } from "../../src/project/instance" import { Provider } from "../../src/provider/provider" import { ProviderTransform } from "../../src/provider/transform" @@ -140,6 +141,20 @@ describe("session.llm.addHistoricalToolStubs", () => { }) }) +describe("session.llm.removedTools", () => { + test("removes a tool the agent denies or the request switches off, and keeps the rest", () => { + const agent = { + permission: PermissionNext.fromConfig({ "*": "allow", altimate_memory_write: "deny" }), + } as Agent.Info + const user = { tools: { altimate_memory_read: false, altimate_memory_audit: true } } as unknown as MessageV2.User + const names = ["altimate_memory_read", "altimate_memory_write", "altimate_memory_audit", "read"] + expect(LLM.removedTools(names, { agent, user })).toEqual(new Set(["altimate_memory_read", "altimate_memory_write"])) + expect(LLM.removedTools(names, { agent, user: { tools: undefined } as unknown as MessageV2.User })).toEqual( + new Set(["altimate_memory_write"]), + ) + }) +}) + type Capture = { url: URL headers: Headers diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 4efc4d80ee..0469bd3772 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -35,6 +35,7 @@ import { Session as LegacySession } from "../../src/session" import { Session } from "@/session/session" import { SessionMessageTable } from "@opencode-ai/core/session/sql" import { LLM } from "../../src/session/llm" +import * as WorkspaceIdentity from "../../src/altimate/workspace/identity" import { MessageV2 } from "../../src/session/message-v2" import { FSUtil } from "@opencode-ai/core/fs-util" import { SessionCompaction } from "../../src/session/compaction" @@ -624,6 +625,56 @@ noLLMServer.instance( ) // altimate_change end +// altimate_change start - the workspace memory line follows the tools the turn keeps +for (const turn of [ + { agent: "analyst", tools: undefined, expected: { read: true, write: false } }, + { agent: "builder", tools: undefined, expected: { read: true, write: true } }, + { agent: "builder", tools: { altimate_memory_read: false }, expected: { read: false, write: true } }, + { agent: "analyst", tools: { altimate_memory_read: false }, expected: { read: false, write: false } }, +] as const) { + noLLMServer.instance( + `prompt loop passes the identity section only the memory tools ${turn.agent} keeps${turn.tools ? " with read switched off on the request" : ""}`, + () => Effect.gen(function* () { + const section = spyOn(WorkspaceIdentity, "systemSection").mockResolvedValue("") + const sent: Pick[] = [] + const stream = spyOn(LLM, "stream").mockImplementation(async (input) => { + sent.push({ tools: input.tools, agent: input.agent, user: input.user }) + const usage = { inputTokens: 10, outputTokens: 5, totalTokens: 15 } + async function* fullStream() { + yield { type: "start" } + yield { type: "start-step" } + yield { type: "text-start", id: "reply" } + yield { type: "text-delta", id: "reply", text: "Done." } + yield { type: "text-end", id: "reply" } + yield { type: "finish-step", finishReason: "stop", usage } + yield { type: "finish", finishReason: "stop", totalUsage: usage } + } + return { fullStream: fullStream() } as unknown as Awaited> + }) + try { + const { prompt, chat } = yield* boot() + yield* prompt.prompt({ + sessionID: chat.id, + agent: turn.agent, + model: promptRef, + tools: turn.tools, + parts: [{ type: "text", text: "What are our naming conventions?" }], + }) + expect(section.mock.calls[0]?.[0]).toEqual({ memoryTools: turn.expected }) + // Same answer as the filter applied to the tools handed to the model call. + const kept = (name: string) => name in sent[0].tools && !LLM.removedTools([name], sent[0]).has(name) + expect({ read: kept("altimate_memory_read"), write: kept("altimate_memory_write") }).toEqual(turn.expected) + } finally { + section.mockRestore() + stream.mockRestore() + } + }), + { config: { ...cfg, snapshot: false, learn: { enabled: false } } }, + 30_000, + ) +} +// altimate_change end + // altimate_change start - approved lessons are harness-delivered without changing cached prefixes for (const change of ["lower budget", "replacement", "disabled config", "disabled env"] as const) { noLLMServer.instance( diff --git a/packages/opencode/test/skill/skill.test.ts b/packages/opencode/test/skill/skill.test.ts index ce8e114abb..5565be3d91 100644 --- a/packages/opencode/test/skill/skill.test.ts +++ b/packages/opencode/test/skill/skill.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test" import { Effect, Layer } from "effect" import { Skill } from "../../src/skill" +import { Agent } from "../../src/agent/agent" import { Discovery } from "../../src/skill/discovery" import { RuntimeFlags } from "../../src/effect/runtime-flags" import { EventV2Bridge } from "../../src/event-v2-bridge" @@ -768,6 +769,10 @@ description: A skill in the .opencode/skills directory. expect(source["project-one"]).toBe("project") expect(Object.values(source)).toContain("built-in") expect(Object.values(source)).not.toContain(undefined) + // The read-only analyst is offered the workspace's skill, labelled as such, like builder. + const analyst = yield* Effect.promise(() => Agent.get("analyst")) + const listing = Skill.fmt(yield* skill.available(analyst), { verbose: true }) + expect(listing).toContain('\n workspace-one') }).pipe(provideInstance(project.path)), ) }),