import { Config } from "@/config/config"
import { Provider } from "@/provider/provider"
import { ModelID, ProviderID } from "../provider/schema"
import { generateObject, streamObject, type ModelMessage } from "ai"
import { Truncate } from "@/tool/truncate"
import { Auth } from "../auth"
import { ProviderTransform } from "@/provider/transform"
import * as Log from "@opencode-ai/core/util/log"
const log = Log.create({ service: "agent" })
import PROMPT_GENERATE from "./generate.txt"
import PROMPT_BUILD from "./prompt/build.txt"
import PROMPT_PLAN from "./prompt/plan.txt"
import PROMPT_COMPACTION from "./prompt/compaction.txt"
import PROMPT_EXPLORE from "./prompt/explore.txt"
import PROMPT_SCOUT from "./prompt/scout.txt"
import PROMPT_SUMMARY from "./prompt/summary.txt"
import PROMPT_TITLE from "./prompt/title.txt"
import PROMPT_GOAL from "./prompt/goal.txt"
import PROMPT_SPEC_VERIFY from "./prompt/spec-verify.txt"
import { Permission } from "@/permission"
import { mergeDeep, pipe, sortBy, values } from "remeda"
import { Global } from "@opencode-ai/core/global"
import path from "path"
import { Plugin } from "@/plugin"
import { Skill } from "../skill"
import { Effect, Context, Layer, Schema } from "effect"
import { InstanceState } from "@/effect/instance-state"
import * as TaskRoute from "@/project/task-route"
import { RuntimeFlags } from "@/effect/runtime-flags"
import * as Option from "effect/Option"
import * as OtelTracer from "@effect/opentelemetry/Tracer"
import { type DeepMutable } from "@opencode-ai/core/schema"
export const Info = Schema.Struct({
name: Schema.String,
description: Schema.optional(Schema.String),
mode: Schema.Literals(["subagent", "primary", "all"]),
native: Schema.optional(Schema.Boolean),
hidden: Schema.optional(Schema.Boolean),
topP: Schema.optional(Schema.Finite),
temperature: Schema.optional(Schema.Finite),
color: Schema.optional(Schema.String),
permission: Permission.Ruleset,
model: Schema.optional(
Schema.Struct({
modelID: ModelID,
providerID: ProviderID,
}),
),
variant: Schema.optional(Schema.String),
prompt: Schema.optional(Schema.String),
options: Schema.Record(Schema.String, Schema.Unknown),
steps: Schema.optional(Schema.Finite),
}).annotate({ identifier: "Agent" })
export type Info = DeepMutable<Schema.Schema.Type<typeof Info>>
const GeneratedAgent = Schema.Struct({
identifier: Schema.String,
whenToUse: Schema.String,
systemPrompt: Schema.String,
})
export interface Interface {
readonly get: (agent: string) => Effect.Effect<Info>
readonly list: () => Effect.Effect<Info[]>
readonly defaultInfo: () => Effect.Effect<Info>
readonly defaultAgent: () => Effect.Effect<string>
readonly generate: (input: {
description: string
model?: { providerID: ProviderID; modelID: ModelID }
}) => Effect.Effect<
{
identifier: string
whenToUse: string
systemPrompt: string
},
Provider.ModelNotFoundError
>
}
type State = Omit<Interface, "generate">
export class Service extends Context.Service<Service, Interface>()("@opencode/Agent") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const auth = yield* Auth.Service
const plugin = yield* Plugin.Service
const skill = yield* Skill.Service
const provider = yield* Provider.Service
const flags = yield* RuntimeFlags.Service
const state = yield* InstanceState.make<State>(
Effect.fn("Agent.state")(function* (ctx) {
const cfg = yield* config.get()
const skillDirs = yield* skill.dirs()
const whitelistedDirs = [
Truncate.GLOB,
path.join(Global.Path.config, "*"),
path.join(Global.Path.tmp, "*"),
...skillDirs.map((dir) => path.join(dir, "*")),
]
const readonlyExternalDirectory = {
"*": "ask",
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
} satisfies Record<string, "allow" | "ask" | "deny">
const defaults = Permission.fromConfig({
"*": "allow",
doom_loop: "ask",
external_directory: {
"*": "ask",
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
},
question: "deny",
plan_enter: "deny",
plan_exit: "deny",
spec_write: "deny",
repo_clone: "deny",
repo_overview: "deny",
verify_ui: "deny",
save_ui_screenshot: "deny",
get_ui_verification_log: "deny",
read: {
"*": "allow",
"*.env": "ask",
"*.env.*": "ask",
"*.env.example": "allow",
},
})
const user = Permission.fromConfig(cfg.permission ?? {})
const agents: Record<string, Info> = {
build: {
name: "build",
description: "The default agent. Executes tools based on configured permissions.",
options: {},
permission: Permission.merge(
defaults,
Permission.fromConfig({
question: "allow",
plan_enter: "ask",
plan_write: "deny",
verify_ui: "allow",
save_ui_screenshot: "allow",
get_ui_verification_log: "allow",
}),
user,
),
mode: "primary",
native: true,
prompt: PROMPT_BUILD,
},
goal: {
name: "goal",
description: "Multi-turn goal-driven agent with verifiable termination",
options: {},
permission: Permission.merge(
defaults,
Permission.fromConfig({
question: "allow",
plan_enter: "allow",
webfetch: "allow",
websearch: "allow",
todowrite: "allow",
spec_write: "allow",
task: "allow",
}),
user,
),
mode: "primary",
native: true,
prompt: PROMPT_GOAL,
color: "info",
},
"spec-verify": {
name: "spec-verify",
description: "Build, deploy, and UI-verify Harmony features against spec",
options: {},
permission: Permission.merge(
defaults,
Permission.fromConfig({
question: "allow",
arkts_knowledge_search: "allow",
verify_ui: "allow",
save_ui_screenshot: "allow",
get_ui_verification_log: "allow",
}),
user,
),
mode: "subagent",
native: true,
hidden: true,
temperature: 0.2,
prompt: PROMPT_SPEC_VERIFY,
},
plan: {
name: "plan",
description: "Plan mode. Disallows all edit tools.",
options: {},
permission: Permission.merge(
defaults,
Permission.fromConfig({
question: "allow",
plan_exit: "ask",
plan_write: "allow",
bash: "deny",
build_project: "deny",
check_ets_files: "deny",
perform_ui_action: "deny",
get_app_ui_tree: "deny",
start_app: "deny",
hdc_log: "deny",
switch_cwd: "deny",
arkts_knowledge_search: "deny",
external_directory: {
[path.join(Global.Path.data, "plans", "*")]: "allow",
},
edit: "deny",
}),
user,
),
mode: "primary",
native: true,
prompt: PROMPT_PLAN,
},
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.`,
permission: Permission.merge(
defaults,
Permission.fromConfig({
todowrite: "deny",
}),
user,
),
options: {},
mode: "subagent",
native: true,
},
explore: {
name: "explore",
permission: Permission.merge(
defaults,
Permission.fromConfig({
"*": "deny",
grep: "allow",
glob: "allow",
list: "allow",
bash: "allow",
webfetch: "allow",
websearch: "allow",
read: "allow",
external_directory: readonlyExternalDirectory,
}),
user,
),
description: `Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.`,
prompt: PROMPT_EXPLORE,
options: {},
mode: "subagent",
native: true,
},
...(flags.experimentalScout
? {
scout: {
name: "scout",
permission: Permission.merge(
defaults,
Permission.fromConfig({
"*": "deny",
grep: "allow",
glob: "allow",
webfetch: "allow",
websearch: "allow",
read: "allow",
repo_clone: "allow",
repo_overview: "allow",
external_directory: {
...readonlyExternalDirectory,
[path.join(Global.Path.repos, "*")]: "allow",
},
}),
user,
),
description: `Docs and dependency-source specialist. Use this when you need to inspect external documentation, clone dependency repositories into the managed cache, and research library implementation details without modifying the user's workspace.`,
prompt: PROMPT_SCOUT,
options: {},
mode: "subagent" as const,
native: true,
},
}
: {}),
compaction: {
name: "compaction",
mode: "primary",
native: true,
hidden: true,
prompt: PROMPT_COMPACTION,
permission: Permission.merge(
defaults,
Permission.fromConfig({
"*": "deny",
}),
user,
),
options: {},
},
title: {
name: "title",
mode: "primary",
options: {},
native: true,
hidden: true,
temperature: 0.5,
permission: Permission.merge(
defaults,
Permission.fromConfig({
"*": "deny",
}),
user,
),
prompt: PROMPT_TITLE,
},
summary: {
name: "summary",
mode: "primary",
options: {},
native: true,
hidden: true,
permission: Permission.merge(
defaults,
Permission.fromConfig({
"*": "deny",
}),
user,
),
prompt: PROMPT_SUMMARY,
},
}
for (const [key, value] of Object.entries(cfg.agent ?? {})) {
if (value.disable) {
delete agents[key]
continue
}
let item = agents[key]
if (!item)
item = agents[key] = {
name: key,
mode: "all",
permission: Permission.merge(defaults, user),
options: {},
native: false,
}
if (value.model) item.model = Provider.parseModel(value.model)
item.variant = value.variant ?? item.variant
item.prompt = value.prompt ?? item.prompt
item.description = value.description ?? item.description
item.temperature = value.temperature ?? item.temperature
item.topP = value.top_p ?? item.topP
item.mode = value.mode ?? item.mode
item.color = value.color ?? item.color
item.hidden = value.hidden ?? item.hidden
item.name = value.name ?? item.name
item.steps = value.steps ?? item.steps
item.options = mergeDeep(item.options, value.options ?? {})
item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {}))
}
for (const name in agents) {
const agent = agents[name]
const explicit = agent.permission.some((r) => {
if (r.permission !== "external_directory") return false
if (r.action !== "deny") return false
return r.pattern === Truncate.GLOB
})
if (explicit) continue
agents[name].permission = Permission.merge(
agents[name].permission,
Permission.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }),
)
}
if (flags.exploreTaskRoute && agents.explore && !cfg.agent?.explore?.prompt) {
const routeContext = TaskRoute.buildExploreContext(ctx.directory)
if (routeContext) {
agents.explore.prompt = `${agents.explore.prompt ?? PROMPT_EXPLORE}\n\n${routeContext}`
}
}
const get = Effect.fnUntraced(function* (agent: string) {
return agents[agent]
})
const list = Effect.fnUntraced(function* () {
const cfg = yield* config.get()
return pipe(
agents,
values(),
sortBy(
[(x) => (cfg.default_agent ? x.name === cfg.default_agent : x.name === "build"), "desc"],
[(x) => x.name, "asc"],
),
)
})
const defaultInfo = Effect.fnUntraced(function* () {
const c = yield* config.get()
if (c.default_agent) {
const agent = agents[c.default_agent]
if (!agent) throw new Error(`default agent "${c.default_agent}" not found`)
if (agent.mode === "subagent") throw new Error(`default agent "${c.default_agent}" is a subagent`)
if (agent.hidden === true) throw new Error(`default agent "${c.default_agent}" is hidden`)
return agent
}
const visible = Object.values(agents).find((a) => a.mode !== "subagent" && a.hidden !== true)
if (!visible) throw new Error("no primary visible agent found")
return visible
})
const defaultAgent = Effect.fnUntraced(function* () {
return (yield* defaultInfo()).name
})
return {
get,
list,
defaultInfo,
defaultAgent,
} satisfies State
}),
)
return Service.of({
get: Effect.fn("Agent.get")(function* (agent: string) {
return yield* InstanceState.useEffect(state, (s) => s.get(agent))
}),
list: Effect.fn("Agent.list")(function* () {
return yield* InstanceState.useEffect(state, (s) => s.list())
}),
defaultInfo: Effect.fn("Agent.defaultInfo")(function* () {
return yield* InstanceState.useEffect(state, (s) => s.defaultInfo())
}),
defaultAgent: Effect.fn("Agent.defaultAgent")(function* () {
return yield* InstanceState.useEffect(state, (s) => s.defaultAgent())
}),
generate: Effect.fn("Agent.generate")(function* (input: {
description: string
model?: { providerID: ProviderID; modelID: ModelID }
}) {
const cfg = yield* config.get()
const model = input.model ?? (yield* provider.defaultModel())
const resolved = yield* provider.getModel(model.providerID, model.modelID)
const language = yield* provider.getLanguage(resolved)
const tracer = cfg.experimental?.openTelemetry
? Option.getOrUndefined(yield* Effect.serviceOption(OtelTracer.OtelTracer))
: undefined
const system = [PROMPT_GENERATE]
yield* plugin.trigger("experimental.chat.system.transform", { model: resolved }, { system })
const existing = yield* InstanceState.useEffect(state, (s) => s.list())
const authInfo = yield* auth.get(model.providerID).pipe(Effect.orDie)
const isOpenaiOauth = model.providerID === "openai" && authInfo?.type === "oauth"
const params = {
experimental_telemetry: {
isEnabled: cfg.experimental?.openTelemetry,
tracer,
metadata: {
userId: cfg.username ?? "unknown",
},
},
temperature: 0.3,
messages: [
...(isOpenaiOauth
? []
: system.map(
(item): ModelMessage => ({
role: "system",
content: item,
}),
)),
{
role: "user",
content: `Create an agent configuration based on this request: "${input.description}".\n\nIMPORTANT: The following identifiers already exist and must NOT be used: ${existing.map((i) => i.name).join(", ")}\n Return ONLY the JSON object, no other text, do not wrap in backticks`,
},
],
model: language,
schema: Object.assign(
Schema.toStandardSchemaV1(GeneratedAgent),
Schema.toStandardJSONSchemaV1(GeneratedAgent),
),
experimental_repairText: async ({ text }: { text: string }) => {
return text.replace(/^```(?:json)?\s*\n?/i, "").replace(/\n?```\s*$/, "")
},
} satisfies Parameters<typeof generateObject>[0]
if (isOpenaiOauth) {
return yield* Effect.promise(async () => {
const result = streamObject({
...params,
providerOptions: ProviderTransform.providerOptions(resolved, {
instructions: system.join("\n"),
store: false,
}),
onError: () => {},
})
for await (const part of result.fullStream) {
if (part.type === "error") throw part.error
}
return result.object
})
}
return yield* Effect.promise(() =>
generateObject(params)
.then((r) => r.object)
.catch((err) => {
log.error("generateObject failed", {
provider: model.providerID,
model: model.modelID,
error: err.message,
cause: err.cause instanceof Error ? err.cause.message : err.cause,
text: err.text ? String(err.text).slice(0, 500) : undefined,
})
throw err
}),
)
}),
})
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(Plugin.defaultLayer),
Layer.provide(Provider.defaultLayer),
Layer.provide(Auth.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(Skill.defaultLayer),
Layer.provide(RuntimeFlags.defaultLayer),
)
export * as Agent from "./agent"