Skip to content
Open
1 change: 1 addition & 0 deletions docs/docs/configure/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions docs/docs/data-engineering/agent-modes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

---

Expand Down
7 changes: 7 additions & 0 deletions packages/opencode/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Comment thread
hkaltai marked this conversation as resolved.
// 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: {
Expand Down
7 changes: 5 additions & 2 deletions packages/opencode/src/altimate/prompts/analyst.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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 memory; when asked to save something, say that the builder agent can.

## 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.
Expand All @@ -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:** <requested value(s), with units>`, or `**Answer:** unable to determine — <why>`; 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 — <why>` 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:** <requested value(s), with units>`, or `**Answer:** unable to determine — <why>`; 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 — <why>` if unresolved); never guess.
40 changes: 31 additions & 9 deletions packages/opencode/src/altimate/workspace/identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -94,12 +97,30 @@ 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 memory; when asked to save something, say that the builder agent 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") {
Expand All @@ -117,6 +138,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,
"",
Expand All @@ -128,7 +150,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 " +
Expand Down Expand Up @@ -342,13 +364,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 {
Expand Down Expand Up @@ -415,7 +437,7 @@ async function withContents(outcome: BindingOutcome, directory: string, identity
return contents ? `${identity}\n\n${contents}` : identity
}

export async function systemSection(): Promise<string> {
export async function systemSection(opts: { memoryTools?: MemoryTools } = {}): Promise<string> {
// 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.
Expand Down Expand Up @@ -443,15 +465,15 @@ export async function systemSection(): Promise<string> {
})
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, () =>
Promise.race([lastKnown(key, directory), after(FALLBACK_BUDGET_MS, () => ({ status: "unknown" }))]),
)
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)
}
Expand Down
4 changes: 3 additions & 1 deletion packages/opencode/src/memory/tools/memory-read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
}
}

Expand Down
15 changes: 9 additions & 6 deletions packages/opencode/src/session/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<StreamInput, "agent" | "user">): Set<string> {
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<StreamInput, "tools" | "agent" | "user">) {
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
Expand Down
15 changes: 14 additions & 1 deletion packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
hkaltai marked this conversation as resolved.
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
Expand Down
19 changes: 19 additions & 0 deletions packages/opencode/test/agent/analyst-data-explorer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
3 changes: 2 additions & 1 deletion packages/opencode/test/altimate/sql-validation-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`")
Expand Down
Loading
Loading