Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
node_modules
.DS_Store
.devcontainer/
coverage
coverage-html
*.lcov
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/utils/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ const defaults: Record<string, string> = {
ask: "var(--icon-agent-ask-base)",
build: "var(--icon-agent-build-base)",
docs: "var(--icon-agent-docs-base)",
learn: "var(--icon-agent-docs-base)",
plan: "var(--icon-agent-plan-base)",
}

Expand Down
20 changes: 20 additions & 0 deletions packages/opencode/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { ProviderTransform } from "@/provider/transform"
import PROMPT_GENERATE from "./generate.txt"
import PROMPT_COMPACTION from "./prompt/compaction.txt"
import PROMPT_EXPLORE from "./prompt/explore.txt"
import PROMPT_LEARN from "./prompt/learn.txt"
import PROMPT_SUMMARY from "./prompt/summary.txt"
import PROMPT_TITLE from "./prompt/title.txt"
import { Permission } from "@/permission"
Expand Down Expand Up @@ -179,6 +180,25 @@ const layer = Layer.effect(
mode: "primary",
native: true,
},
learn: {
name: "learn",
description: "Learn mode. Explores the codebase without making changes.",
options: {},
permission: Permission.merge(
defaults,
Permission.fromConfig({
"*": "deny",
read: "allow",
glob: "allow",
grep: "allow",
webfetch: "allow",
websearch: "allow",
}),
),
mode: "primary",
native: true,
prompt: PROMPT_LEARN,
},
general: {
name: "general",
description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`,
Expand Down
3 changes: 3 additions & 0 deletions packages/opencode/src/agent/prompt/learn.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Learn mode is active. Help the user understand the codebase, answer questions, and explain possible changes.

Do not attempt to edit files, run shell commands, invoke subagents, or make any other changes. If the user asks for an implementation, explain the change that would be needed and ask them to switch to Build mode when they want it applied.
11 changes: 11 additions & 0 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1349,6 +1349,11 @@ const layer = Layer.effect(
const shell: (input: ShellInput) => Effect.Effect<SessionV1.WithParts, Session.BusyError> = Effect.fn(
"SessionPrompt.shell",
)(function* (input: ShellInput) {
if (input.agent === "learn") {
const error = new NamedError.Unknown({ message: "Learn mode does not allow shell commands." })
yield* events.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() })
throw error
}
const ready = yield* Latch.make()
return yield* state.startShell(input.sessionID, lastAssistant(input.sessionID), shellImpl(input, ready), ready)
})
Expand All @@ -1369,6 +1374,12 @@ const layer = Layer.effect(
}
const agentName = cmd.agent ?? input.agent

if (input.agent === "learn" || agentName === "learn") {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Should Learn mode block every slash command here, or only commands that can modify the repository? As written, this appears to prevent read-only learning commands such as /group and /newcomer from running while Learn mode is active. Since these features are intended to integrate with Learn mode, it may be useful to allow explicitly read-only commands while continuing to block shell expansions and mutation-capable commands.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Great note. Yes, Learn mode will allow a specific list of commands, and I think this part should be reserved for another PR, when we move onto the next stage of the project, which is the integration of the Learn Mode.

const error = new NamedError.Unknown({ message: "Learn mode does not allow commands." })
yield* events.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() })
throw error
}

const raw = input.arguments.match(argsRegex) ?? []
const args = raw.map((arg) => arg.replace(quoteTrimRegex, ""))
const templateCommand = yield* Effect.promise(async () => cmd.template)
Expand Down
12 changes: 12 additions & 0 deletions packages/opencode/src/session/reminders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { Session } from "./session"
import PROMPT_PLAN from "./prompt/plan.txt"
import BUILD_SWITCH from "./prompt/build-switch.txt"
import PLAN_MODE from "./prompt/plan-mode.txt"
import PROMPT_LEARN from "../agent/prompt/learn.txt"

export const apply = Effect.fn("SessionReminders.apply")(function* (input: {
messages: SessionV1.WithParts[]
Expand All @@ -23,6 +24,17 @@ export const apply = Effect.fn("SessionReminders.apply")(function* (input: {
const userMessage = input.messages.findLast((msg) => msg.info.role === "user")
if (!userMessage) return input.messages

if (input.agent.name === "learn") {
userMessage.parts.push({
id: PartID.ascending(),
messageID: userMessage.info.id,
sessionID: userMessage.info.sessionID,
type: "text",
text: PROMPT_LEARN,
synthetic: true,
})
}

if (!flags.experimentalPlanMode) {
if (input.agent.name === "plan") {
userMessage.parts.push({
Expand Down
3 changes: 3 additions & 0 deletions packages/opencode/src/session/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
providerID: input.model.providerID,
agent: input.agent,
permission: input.session.permission,
readOnly: input.agent.name === "learn",
})) {
const schema = ProviderTransform.schema(input.model, ToolJsonSchema.fromTool(item))
tools[item.id] = tool({
Expand Down Expand Up @@ -133,6 +134,8 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
})
}

if (input.agent.name === "learn") return tools

const hasMcpResourceServer = Object.values(yield* mcp.clients()).some(
(client) => !!client.getServerCapabilities()?.resources,
)
Expand Down
6 changes: 5 additions & 1 deletion packages/opencode/src/tool/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export interface Interface {
modelID: ModelV2.ID
agent: Agent.Info
permission?: PermissionV1.Ruleset
readOnly?: boolean
}) => Effect.Effect<Tool.Def[]>
}

Expand Down Expand Up @@ -284,7 +285,10 @@ const layer = Layer.effect(
})

const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (input) {
const filtered = (yield* all()).filter((tool) => {
const s = yield* InstanceState.get(state)
const readonly = new Set([ReadTool.id, GlobTool.id, GrepTool.id, WebFetchTool.id, WebSearchTool.id])
const source = input.readOnly ? s.builtin.filter((tool) => readonly.has(tool.id)) : [...s.builtin, ...s.custom]
const filtered = source.filter((tool) => {
if (tool.id === WebSearchTool.id) {
return webSearchEnabled(input.providerID, { exa: flags.enableExa, parallel: flags.enableParallel })
}
Expand Down
16 changes: 16 additions & 0 deletions packages/opencode/test/agent/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ it.instance("returns default native agents when no config", () =>
const agents = yield* load((svc) => svc.list())
const names = agents.map((a) => a.name)
expect(names).toContain("build")
expect(names).toContain("learn")
expect(names).toContain("plan")
expect(names).toContain("general")
expect(names).toContain("explore")
Expand Down Expand Up @@ -80,6 +81,20 @@ it.instance("plan agent denies edits except .opencode/plans/*", () =>
}),
)

it.instance("learn agent only permits read-only tools", () =>
Effect.gen(function* () {
const learn = yield* load((svc) => svc.get("learn"))
expect(learn).toBeDefined()
expect(learn?.mode).toBe("primary")
expect(evalPerm(learn, "read")).toBe("allow")
expect(evalPerm(learn, "glob")).toBe("allow")
expect(evalPerm(learn, "grep")).toBe("allow")
expect(evalPerm(learn, "edit")).toBe("deny")
expect(evalPerm(learn, "bash")).toBe("deny")
expect(evalPerm(learn, "task")).toBe("deny")
}),
)

it.instance("plan agent denies the general subagent by default", () =>
Effect.gen(function* () {
const plan = yield* load((svc) => svc.get("plan"))
Expand Down Expand Up @@ -748,6 +763,7 @@ it.instance(
config: {
agent: {
build: { disable: true },
learn: { disable: true },
plan: { disable: true },
},
},
Expand Down
39 changes: 39 additions & 0 deletions packages/opencode/test/session/prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1533,6 +1533,45 @@ it.instance("shell rejects with BusyError when loop running", () =>
}),
)

noLLMServer.instance("learn mode blocks direct shell commands before they execute", () =>
Effect.gen(function* () {
const { directory } = yield* TestInstance
const { prompt, chat } = yield* boot()
const marker = path.join(directory, ".learn-shell-probe")
const exit = yield* prompt
.shell({ sessionID: chat.id, agent: "learn", command: ": > .learn-shell-probe" })
.pipe(Effect.exit)

expect(Exit.isFailure(exit)).toBe(true)
expect(yield* Effect.promise(() => Bun.file(marker).exists())).toBe(false)
}),
)

noLLMServer.instance(
"learn mode blocks command shell expansions before they execute",
() =>
Effect.gen(function* () {
const { directory } = yield* TestInstance
const { prompt, chat } = yield* boot()
const marker = path.join(directory, ".learn-command-probe")
const exit = yield* prompt
.command({ sessionID: chat.id, agent: "learn", command: "write", arguments: "" })
.pipe(Effect.exit)

expect(Exit.isFailure(exit)).toBe(true)
expect(yield* Effect.promise(() => Bun.file(marker).exists())).toBe(false)
}),
{
config: {
command: {
write: {
template: "!`: > .learn-command-probe`",
},
},
},
},
)

unixNoLLMServer(
"shell captures stdout and stderr in completed tool output",
() =>
Expand Down
18 changes: 18 additions & 0 deletions packages/opencode/test/tool/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,24 @@ describe("tool.registry", () => {
}),
)

it.instance("limits read-only prompts to built-in read-only tools", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const agents = yield* Agent.Service
const learn = yield* agents.get("learn")
if (!learn) throw new Error("learn agent not found")

const tools = yield* registry.tools({
providerID: ProviderV2.ID.opencode,
modelID: ModelV2.ID.make("test"),
agent: learn,
readOnly: true,
})

expect(tools.map((tool) => tool.id)).toEqual(["read", "glob", "grep", "webfetch", "websearch"])
}),
)

it.instance("loads tools from .opencode/tool (singular)", () =>
Effect.gen(function* () {
const test = yield* TestInstance
Expand Down
2 changes: 2 additions & 0 deletions packages/session-ui/src/components/message-part.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -382,12 +382,14 @@ const agentTones: Record<string, string> = {
ask: "var(--icon-agent-ask-base)",
build: "var(--icon-agent-build-base)",
docs: "var(--icon-agent-docs-base)",
learn: "var(--icon-agent-docs-base)",
plan: "var(--icon-agent-plan-base)",
}

const v2AgentTones: Record<string, string> = {
build: "var(--v2-agent-build-solid)",
explore: "var(--v2-agent-explore-solid)",
learn: "var(--v2-agent-explore-solid)",
plan: "var(--v2-agent-plan-solid)",
review: "var(--v2-agent-review-solid)",
writer: "var(--v2-agent-writer-solid)",
Expand Down
18 changes: 17 additions & 1 deletion packages/tui/src/routes/home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,21 @@ import { HomeSessionDestinationProvider } from "./home/session-destination"
let once = false
const placeholder = {
normal: ["Fix a TODO in the codebase", "What is the tech stack of this project?", "Fix broken tests"],
learn: [
"Tell me about Copilot integration...",
"Explain how this codebase is organized",
"Where should I start reading this project?",
],
shell: ["ls -la", "git status", "pwd"],
}

export function homePromptPlaceholders(agent?: string) {
return {
normal: agent === "learn" ? placeholder.learn : placeholder.normal,
shell: placeholder.shell,
}
}

export function Home() {
const pluginRuntime = usePluginRuntime()
const sync = useSync()
Expand Down Expand Up @@ -80,7 +92,11 @@ export function Home() {
<box height={1} minHeight={0} flexShrink={1} />
<box width="100%" maxWidth={promptMaxWidth()} zIndex={1000} paddingTop={1} flexShrink={0}>
<pluginRuntime.Slot name="home_prompt" mode="replace" ref={bind}>
<Prompt ref={bind} right={<pluginRuntime.Slot name="home_prompt_right" />} placeholders={placeholder} />
<Prompt
ref={bind}
right={<pluginRuntime.Slot name="home_prompt_right" />}
placeholders={homePromptPlaceholders(local.agent.current()?.name)}
/>
</pluginRuntime.Slot>
</box>
<pluginRuntime.Slot name="home_bottom" />
Expand Down
12 changes: 12 additions & 0 deletions packages/tui/test/home.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { expect, test } from "bun:test"
import { homePromptPlaceholders } from "../src/routes/home"

test("uses the Learn placeholder only while Learn is selected", () => {
expect(homePromptPlaceholders("learn").normal).toEqual([
"Tell me about Copilot integration...",
"Explain how this codebase is organized",
"Where should I start reading this project?",
])
expect(homePromptPlaceholders("plan").normal).toContain("Fix broken tests")
expect(homePromptPlaceholders("build").normal).toContain("Fix broken tests")
})
Loading