bacfa4ae62
* refactor(opencode): migrate agent promise helpers to kilocode module and adopt Effect-based tests Remove legacy promise-based helpers (`get`, `list`, `defaultAgent`, `remove`) from the core agent module and update callsites to use the dedicated `@/kilocode/agent` module directly. Migrate kilocode-specific tests from `WithInstance.provide` patterns to the `testEffect` helper with Effect generators for cleaner, more idiomatic test code. - Remove `makeRuntime` import and exported promise helpers from agent.ts - Update HTTP API handlers to import from `@/kilocode/agent` instead of re-exported `Agent` namespace - Rewrite agent tests to use `load()` helper or Effect service access - Convert agent-global-config-dirs and agent-skill-permissions tests to `testEffect` pattern - Reorder SDK type definitions (BackgroundProcessLogs/WorkspaceWarpError) * chore(auth): remove legacy promise helpers and replace with direct Effect service access Eliminate the exported promise-based convenience functions (`get`, `all`, `set`, `remove`) from the Auth module and replace all callsites with explicit `AppRuntime.runPromise` or `makeRuntime` invocations that access `Auth.Service` directly through Effect's service pattern. - Delete `makeRuntime`-backed promise helpers from auth/index.ts - Update CLI entrypoint, kilo-sessions, indexing, and server instance to use `AppRuntime.runPromise(Auth.Service.use(...))` for auth access - Inject `Auth.Service` as a dependency into the ModelsDev layer and consume it via Effect generator instead of promise wrapper - Create a local `makeRuntime` instance in model-cache.ts for non-Effect callsites that still need promise-based auth access - Rewrite tests to manipulate auth.json directly on disk with proper save/restore semantics instead of relying on removed helpers * refactor(suggestion): convert suggest tool to Effect-native with injected Command dependency Transform the suggestion tool from async/promise-based implementation to idiomatic Effect generators with explicit dependency injection of the Command service rather than importing and calling module-level helpers. - Convert `resolvePrompt` from async function to Effect generator that accepts a `Command.Interface` parameter - Refactor `SuggestTool` definition to yield `Command.Service` from the Effect context and thread it through to `resolvePrompt` - Add `Command.Service` as a dependency to the tool registry layer and provide `Command.defaultLayer` in both production and test wiring - Remove unused `makeRuntime` import and exported `get` helper from command/index.ts - Add explicit type annotations to Auth delegate in server instance - Rewrite suggestion tests to use `testEffect` helper with a mock `Command.Service` layer instead of spying on module exports * feat(git): migrate WorktreeFamily to Effect service and wire Git.Service as dependency Convert WorktreeFamily.list from an async function using legacy promise helpers to an Effect generator that yields Git.Service from context, eliminating the need for the removed `run` promise wrapper in git/index. - Replace `WorktreeFamily.list()` async function with Effect.fn generator that obtains Git.Service and InstanceState from the Effect context - Remove legacy `makeRuntime`/`run`/`runPromise` exports from git module - Update RecallTool to thread Git.Service through to WorktreeFamily calls via EffectBridge - Add Git.Service as a required dependency in tool registry and HTTP server route layers - Update all test layers to provide Git.defaultLayer * chore(mcp): replace legacy promise helpers with Effect-native AppRuntime calls Remove exported promise-based `status`, `connect`, and `disconnect` helpers from MCP module and convert the network recovery callsite in SessionNetwork to use AppRuntime.runPromise with Effect.gen directly. * refactor(auth): adopt makeRuntime helper for Auth service resolution in kilo modules Replace AppRuntime.runPromise with locally scoped makeRuntime instances in kilo-sessions and kilocode/indexing modules, removing the dependency on the global AppRuntime singleton for Auth service access. * fix(test): simplify cleanup error handling in provider test Replace try-catch block with promise .catch() for file unlink operation during test teardown.
434 lines
16 KiB
TypeScript
434 lines
16 KiB
TypeScript
import { Config } from "@/config/config"
|
|
import z from "zod"
|
|
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 PROMPT_GENERATE from "./generate.txt"
|
|
import PROMPT_COMPACTION from "./prompt/compaction.txt"
|
|
import PROMPT_EXPLORE from "./prompt/explore.txt"
|
|
import PROMPT_SUMMARY from "./prompt/summary.txt"
|
|
import PROMPT_TITLE from "./prompt/title.txt"
|
|
import { Permission } from "@/permission"
|
|
import { mergeDeep, pipe, sortBy, values } from "remeda"
|
|
import { Global } from "@opencode-ai/core/global"
|
|
import { KilocodePaths } from "@/kilocode/paths" // kilocode_change
|
|
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 { zod } from "@/util/effect-zod"
|
|
import { withStatics, type DeepMutable } from "@/util/schema"
|
|
import * as KiloAgent from "@/kilocode/agent" // kilocode_change
|
|
|
|
export const Info = Schema.Struct({
|
|
name: Schema.String,
|
|
displayName: Schema.optional(Schema.String), // kilocode_change - human-readable name for org modes
|
|
description: Schema.optional(Schema.String),
|
|
deprecated: Schema.optional(Schema.Boolean), // kilocode_change
|
|
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" })
|
|
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
|
export type Info = DeepMutable<Schema.Schema.Type<typeof Info>>
|
|
|
|
export interface Interface {
|
|
readonly get: (agent: string) => Effect.Effect<Info>
|
|
readonly list: () => 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
|
|
}>
|
|
}
|
|
|
|
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 state = yield* InstanceState.make<State>(
|
|
Effect.fn("Agent.state")(function* (ctx) {
|
|
const cfg = yield* config.get()
|
|
const skillDirs = yield* skill.dirs()
|
|
// kilocode_change start - include global config dirs so agents can read them without prompting
|
|
const whitelistedDirs = [
|
|
Truncate.GLOB,
|
|
path.join(Global.Path.tmp, "*"),
|
|
...skillDirs.map((dir) => path.join(dir, "*")),
|
|
path.join(Global.Path.config, "*"),
|
|
...KilocodePaths.globalDirs().map((dir) => path.join(dir, "*")),
|
|
]
|
|
// kilocode_change end
|
|
|
|
const baseDefaults = Permission.fromConfig({
|
|
// kilocode_change: renamed from defaults
|
|
"*": "allow",
|
|
doom_loop: "ask",
|
|
external_directory: {
|
|
"*": "ask",
|
|
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
|
|
},
|
|
suggest: "deny", // kilocode_change
|
|
question: "deny",
|
|
plan_enter: "deny",
|
|
plan_exit: "deny",
|
|
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
|
|
read: {
|
|
"*": "allow",
|
|
"*.env": "ask",
|
|
"*.env.*": "ask",
|
|
"*.env.example": "allow",
|
|
},
|
|
})
|
|
|
|
// kilocode_change start - patch defaults with bash allowlist and recall permission
|
|
const kilo = KiloAgent.prepare(cfg)
|
|
const defaults = Permission.merge(baseDefaults, kilo.defaultsPatch)
|
|
// kilocode_change end
|
|
|
|
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",
|
|
suggest: "allow", // kilocode_change
|
|
plan_enter: "allow",
|
|
}),
|
|
user,
|
|
),
|
|
mode: "primary",
|
|
native: true,
|
|
},
|
|
plan: {
|
|
name: "plan",
|
|
description: "Plan mode. Disallows all edit tools.",
|
|
options: {},
|
|
permission: Permission.merge(
|
|
defaults,
|
|
Permission.fromConfig({
|
|
question: "allow",
|
|
plan_exit: "allow",
|
|
external_directory: {
|
|
[path.join(Global.Path.data, "plans", "*")]: "allow",
|
|
},
|
|
edit: {
|
|
"*": "deny",
|
|
[path.join(".opencode", "plans", "*.md")]: "allow",
|
|
[path.relative(ctx.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]: "allow",
|
|
},
|
|
}),
|
|
user,
|
|
),
|
|
mode: "primary",
|
|
native: true,
|
|
},
|
|
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: {
|
|
"*": "ask",
|
|
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
|
|
},
|
|
}),
|
|
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,
|
|
},
|
|
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,
|
|
},
|
|
}
|
|
|
|
// kilocode_change start - rename build→code, add debug/orchestrator/ask, patch plan/explore
|
|
KiloAgent.patchAgents(agents, defaults, user, cfg, kilo, ctx.worktree, whitelistedDirs)
|
|
// kilocode_change end
|
|
|
|
// kilocode_change start - preprocess config to remap "build" key → "code"
|
|
const agentConfigs = KiloAgent.preprocessConfig(cfg.agent ?? {})
|
|
for (const [key, value] of Object.entries(agentConfigs)) {
|
|
// kilocode_change end
|
|
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 ?? {}))
|
|
KiloAgent.processConfigItem(item) // kilocode_change - populate displayName from options
|
|
}
|
|
|
|
// Ensure Truncate.GLOB is allowed unless explicitly configured
|
|
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" } }),
|
|
)
|
|
}
|
|
|
|
const get = Effect.fnUntraced(function* (agent: string) {
|
|
return agents[KiloAgent.resolveKey(agent)] // kilocode_change - treat "build" as "code"
|
|
})
|
|
|
|
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 === "code"), "desc"], // kilocode_change - renamed from "build" to "code"
|
|
[(x) => x.name, "asc"],
|
|
),
|
|
)
|
|
})
|
|
|
|
const defaultAgent = Effect.fnUntraced(function* () {
|
|
const c = yield* config.get()
|
|
if (c.default_agent) {
|
|
const effective = KiloAgent.resolveKey(c.default_agent) // kilocode_change - treat "build" as "code"
|
|
const agent = agents[effective] // kilocode_change
|
|
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.name
|
|
}
|
|
// kilocode_change start - prefer "code" as default agent (key order changes after rename from "build")
|
|
const code = agents.code
|
|
if (code && code.mode !== "subagent" && code.hidden !== true) return code.name
|
|
// kilocode_change end
|
|
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.name
|
|
})
|
|
|
|
return {
|
|
get,
|
|
list,
|
|
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())
|
|
}),
|
|
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 system = [PROMPT_GENERATE]
|
|
yield* plugin.trigger("experimental.chat.system.transform", { model: resolved }, { system })
|
|
const existing = yield* InstanceState.useEffect(state, (s) => s.list())
|
|
|
|
// TODO: clean this up so provider specific logic doesnt bleed over
|
|
const authInfo = yield* auth.get(model.providerID).pipe(Effect.orDie)
|
|
const isOpenaiOauth = model.providerID === "openai" && authInfo?.type === "oauth"
|
|
|
|
const params = {
|
|
// kilocode_change start - enable telemetry with custom PostHog tracer
|
|
experimental_telemetry: KiloAgent.telemetryOptions(cfg),
|
|
// kilocode_change end
|
|
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: z.object({
|
|
identifier: z.string(),
|
|
whenToUse: z.string(),
|
|
systemPrompt: z.string(),
|
|
}),
|
|
} 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))
|
|
}),
|
|
})
|
|
}),
|
|
)
|
|
|
|
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),
|
|
)
|
|
|
|
export * as Agent from "./agent"
|