Merge remote-tracking branch 'origin/dev' into feat/custom-agents

# Conflicts:
#	src/agents/utils.test.ts
#	src/plugin-handlers/agent-config-handler.ts
This commit is contained in:
edxeth
2026-02-26 18:53:29 +01:00
147 changed files with 6360 additions and 1763 deletions
@@ -14,6 +14,7 @@ export interface CategoryResolutionResult {
agentToUse: string
categoryModel: { providerID: string; modelID: string; variant?: string } | undefined
categoryPromptAppend: string | undefined
maxPromptTokens?: number
modelInfo: ModelFallbackInfo | undefined
actualModel: string | undefined
isUnstableAgent: boolean
@@ -51,6 +52,7 @@ export async function resolveCategoryExecution(
agentToUse: "",
categoryModel: undefined,
categoryPromptAppend: undefined,
maxPromptTokens: undefined,
modelInfo: undefined,
actualModel: undefined,
isUnstableAgent: false,
@@ -68,6 +70,7 @@ Available categories: ${allCategoryNames}`,
agentToUse: "",
categoryModel: undefined,
categoryPromptAppend: undefined,
maxPromptTokens: undefined,
modelInfo: undefined,
actualModel: undefined,
isUnstableAgent: false,
@@ -111,6 +114,7 @@ Available categories: ${allCategoryNames}`,
agentToUse: "",
categoryModel: undefined,
categoryPromptAppend: undefined,
maxPromptTokens: undefined,
modelInfo: undefined,
actualModel: undefined,
isUnstableAgent: false,
@@ -154,6 +158,7 @@ Available categories: ${allCategoryNames}`,
agentToUse: "",
categoryModel: undefined,
categoryPromptAppend: undefined,
maxPromptTokens: undefined,
modelInfo: undefined,
actualModel: undefined,
isUnstableAgent: false,
@@ -177,6 +182,7 @@ Available categories: ${categoryNames.join(", ")}`,
agentToUse: SISYPHUS_JUNIOR_AGENT,
categoryModel,
categoryPromptAppend,
maxPromptTokens: resolved.config.max_prompt_tokens,
modelInfo,
actualModel,
isUnstableAgent,
+2 -2
View File
@@ -208,10 +208,10 @@ You are NOT an interactive assistant. You are an autonomous problem-solver.
export const DEFAULT_CATEGORIES: Record<string, CategoryConfig> = {
"visual-engineering": { model: "google/gemini-3-pro", variant: "high" },
"visual-engineering": { model: "google/gemini-3.1-pro", variant: "high" },
ultrabrain: { model: "openai/gpt-5.3-codex", variant: "xhigh" },
deep: { model: "openai/gpt-5.3-codex", variant: "medium" },
artistry: { model: "google/gemini-3-pro", variant: "high" },
artistry: { model: "google/gemini-3.1-pro", variant: "high" },
quick: { model: "anthropic/claude-haiku-4-5" },
"unspecified-low": { model: "anthropic/claude-sonnet-4-6" },
"unspecified-high": { model: "anthropic/claude-opus-4-6", variant: "max" },
+32 -18
View File
@@ -1,5 +1,21 @@
import type { BuildSystemContentInput } from "./types"
import { buildPlanAgentSystemPrepend, isPlanAgent } from "./constants"
import { buildSystemContentWithTokenLimit } from "./token-limiter"
const FREE_OR_LOCAL_PROMPT_TOKEN_LIMIT = 24000
function usesFreeOrLocalModel(model: { providerID: string; modelID: string; variant?: string } | undefined): boolean {
if (!model) {
return false
}
const provider = model.providerID.toLowerCase()
const modelId = model.modelID.toLowerCase()
return provider.includes("local")
|| provider === "ollama"
|| provider === "lmstudio"
|| modelId.includes("free")
}
/**
* Build the system content to inject into the agent prompt.
@@ -8,7 +24,11 @@ import { buildPlanAgentSystemPrepend, isPlanAgent } from "./constants"
export function buildSystemContent(input: BuildSystemContentInput): string | undefined {
const {
skillContent,
skillContents,
categoryPromptAppend,
agentsContext,
maxPromptTokens,
model,
agentName,
availableCategories,
availableSkills,
@@ -18,23 +38,17 @@ export function buildSystemContent(input: BuildSystemContentInput): string | und
? buildPlanAgentSystemPrepend(availableCategories, availableSkills)
: ""
if (!skillContent && !categoryPromptAppend && !planAgentPrepend) {
return undefined
}
const effectiveMaxPromptTokens = maxPromptTokens
?? (usesFreeOrLocalModel(model) ? FREE_OR_LOCAL_PROMPT_TOKEN_LIMIT : undefined)
const parts: string[] = []
if (planAgentPrepend) {
parts.push(planAgentPrepend)
}
if (skillContent) {
parts.push(skillContent)
}
if (categoryPromptAppend) {
parts.push(categoryPromptAppend)
}
return parts.join("\n\n") || undefined
return buildSystemContentWithTokenLimit(
{
skillContent,
skillContents,
categoryPromptAppend,
agentsContext: agentsContext ?? planAgentPrepend,
planAgentPrepend,
},
effectiveMaxPromptTokens
)
}
+5 -4
View File
@@ -5,17 +5,18 @@ import { discoverSkills } from "../../features/opencode-skill-loader"
export async function resolveSkillContent(
skills: string[],
options: { gitMasterConfig?: GitMasterConfig; browserProvider?: BrowserAutomationProvider, disabledSkills?: Set<string>, directory?: string }
): Promise<{ content: string | undefined; error: string | null }> {
): Promise<{ content: string | undefined; contents: string[]; error: string | null }> {
if (skills.length === 0) {
return { content: undefined, error: null }
return { content: undefined, contents: [], error: null }
}
const { resolved, notFound } = await resolveMultipleSkillsAsync(skills, options)
if (notFound.length > 0) {
const allSkills = await discoverSkills({ includeClaudeCodePaths: true, directory: options?.directory })
const available = allSkills.map(s => s.name).join(", ")
return { content: undefined, error: `Skills not found: ${notFound.join(", ")}. Available: ${available}` }
return { content: undefined, contents: [], error: `Skills not found: ${notFound.join(", ")}. Available: ${available}` }
}
return { content: Array.from(resolved.values()).join("\n\n"), error: null }
const contents = Array.from(resolved.values())
return { content: contents.join("\n\n"), contents, error: null }
}
@@ -0,0 +1,121 @@
declare const require: (name: string) => unknown
const { describe, test, expect } = require("bun:test") as {
describe: (name: string, fn: () => void) => void
test: (name: string, fn: () => void) => void
expect: (value: unknown) => {
toBe: (expected: unknown) => void
toContain: (expected: string) => void
not: {
toContain: (expected: string) => void
}
toBeLessThanOrEqual: (expected: number) => void
toBeUndefined: () => void
}
}
import {
buildSystemContentWithTokenLimit,
estimateTokenCount,
truncateToTokenBudget,
} from "./token-limiter"
describe("token-limiter", () => {
test("estimateTokenCount uses 1 token per 4 chars approximation", () => {
// given
const text = "12345678"
// when
const result = estimateTokenCount(text)
// then
expect(result).toBe(2)
})
test("truncateToTokenBudget keeps text within requested token budget", () => {
// given
const content = "A".repeat(120)
const maxTokens = 10
// when
const result = truncateToTokenBudget(content, maxTokens)
// then
expect(estimateTokenCount(result)).toBeLessThanOrEqual(maxTokens)
})
test("buildSystemContentWithTokenLimit returns undefined when there is no content", () => {
// given
const input = {
skillContent: undefined,
skillContents: [],
categoryPromptAppend: undefined,
agentsContext: undefined,
planAgentPrepend: "",
}
// when
const result = buildSystemContentWithTokenLimit(input, 20)
// then
expect(result).toBeUndefined()
})
test("buildSystemContentWithTokenLimit truncates skills before category and agents context", () => {
// given
const input = {
skillContents: [
"SKILL_ALPHA:" + "a".repeat(180),
"SKILL_BETA:" + "b".repeat(180),
],
categoryPromptAppend: "CATEGORY_APPEND:keep",
agentsContext: "AGENTS_CONTEXT:keep",
planAgentPrepend: "",
}
// when
const result = buildSystemContentWithTokenLimit(input, 80)
// then
expect(result).toContain("AGENTS_CONTEXT:keep")
expect(result).toContain("CATEGORY_APPEND:keep")
expect(result).toContain("SKILL_ALPHA:")
expect(estimateTokenCount(result as string)).toBeLessThanOrEqual(80)
})
test("buildSystemContentWithTokenLimit truncates category after skills are exhausted", () => {
// given
const input = {
skillContents: ["SKILL_ALPHA:" + "a".repeat(220)],
categoryPromptAppend: "CATEGORY_APPEND:" + "c".repeat(220),
agentsContext: "AGENTS_CONTEXT:keep",
planAgentPrepend: "",
}
// when
const result = buildSystemContentWithTokenLimit(input, 30)
// then
expect(result).toContain("AGENTS_CONTEXT:keep")
expect(result).not.toContain("SKILL_ALPHA:" + "a".repeat(80))
expect(estimateTokenCount(result as string)).toBeLessThanOrEqual(30)
})
test("buildSystemContentWithTokenLimit truncates agents context last", () => {
// given
const input = {
skillContents: ["SKILL_ALPHA:" + "a".repeat(220)],
categoryPromptAppend: "CATEGORY_APPEND:" + "c".repeat(220),
agentsContext: "AGENTS_CONTEXT:" + "g".repeat(220),
planAgentPrepend: "",
}
// when
const result = buildSystemContentWithTokenLimit(input, 10)
// then
expect(result).toContain("AGENTS_CONTEXT:")
expect(result).not.toContain("SKILL_ALPHA:")
expect(result).not.toContain("CATEGORY_APPEND:")
expect(estimateTokenCount(result as string)).toBeLessThanOrEqual(10)
})
})
+117
View File
@@ -0,0 +1,117 @@
import type { BuildSystemContentInput } from "./types"
const CHARACTERS_PER_TOKEN = 4
export function estimateTokenCount(text: string): number {
if (!text) {
return 0
}
return Math.ceil(text.length / CHARACTERS_PER_TOKEN)
}
export function truncateToTokenBudget(content: string, maxTokens: number): string {
if (!content || maxTokens <= 0) {
return ""
}
const maxCharacters = maxTokens * CHARACTERS_PER_TOKEN
if (content.length <= maxCharacters) {
return content
}
return content.slice(0, maxCharacters)
}
function joinSystemParts(parts: string[]): string | undefined {
const filtered = parts.filter((part) => part.trim().length > 0)
if (filtered.length === 0) {
return undefined
}
return filtered.join("\n\n")
}
function reduceSegmentToFitBudget(content: string, overflowTokens: number): string {
if (overflowTokens <= 0 || !content) {
return content
}
const currentTokens = estimateTokenCount(content)
const nextBudget = Math.max(0, currentTokens - overflowTokens)
return truncateToTokenBudget(content, nextBudget)
}
export function buildSystemContentWithTokenLimit(
input: BuildSystemContentInput,
maxTokens: number | undefined
): string | undefined {
const skillParts = input.skillContents?.length
? [...input.skillContents]
: input.skillContent
? [input.skillContent]
: []
const categoryPromptAppend = input.categoryPromptAppend ?? ""
const agentsContext = input.agentsContext ?? input.planAgentPrepend ?? ""
if (maxTokens === undefined) {
return joinSystemParts([agentsContext, ...skillParts, categoryPromptAppend])
}
let nextSkills = [...skillParts]
let nextCategoryPromptAppend = categoryPromptAppend
let nextAgentsContext = agentsContext
const buildCurrentContent = (): string | undefined =>
joinSystemParts([nextAgentsContext, ...nextSkills, nextCategoryPromptAppend])
let systemContent = buildCurrentContent()
if (!systemContent) {
return undefined
}
let overflowTokens = estimateTokenCount(systemContent) - maxTokens
if (overflowTokens > 0) {
for (let index = 0; index < nextSkills.length && overflowTokens > 0; index += 1) {
const skill = nextSkills[index]
const reducedSkill = reduceSegmentToFitBudget(skill, overflowTokens)
nextSkills[index] = reducedSkill
systemContent = buildCurrentContent()
if (!systemContent) {
return undefined
}
overflowTokens = estimateTokenCount(systemContent) - maxTokens
}
nextSkills = nextSkills.filter((skill) => skill.trim().length > 0)
systemContent = buildCurrentContent()
if (!systemContent) {
return undefined
}
overflowTokens = estimateTokenCount(systemContent) - maxTokens
}
if (overflowTokens > 0 && nextCategoryPromptAppend) {
nextCategoryPromptAppend = reduceSegmentToFitBudget(nextCategoryPromptAppend, overflowTokens)
systemContent = buildCurrentContent()
if (!systemContent) {
return undefined
}
overflowTokens = estimateTokenCount(systemContent) - maxTokens
}
if (overflowTokens > 0 && nextAgentsContext) {
nextAgentsContext = reduceSegmentToFitBudget(nextAgentsContext, overflowTokens)
systemContent = buildCurrentContent()
if (!systemContent) {
return undefined
}
}
if (!systemContent) {
return undefined
}
return truncateToTokenBudget(systemContent, maxTokens)
}
+14 -14
View File
@@ -17,7 +17,7 @@ const TEST_AVAILABLE_MODELS = new Set([
"anthropic/claude-opus-4-6",
"anthropic/claude-sonnet-4-6",
"anthropic/claude-haiku-4-5",
"google/gemini-3-pro",
"google/gemini-3.1-pro",
"google/gemini-3-flash",
"openai/gpt-5.2",
"openai/gpt-5.3-codex",
@@ -52,7 +52,7 @@ describe("sisyphus-task", () => {
providerModelsSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
models: {
anthropic: ["claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4-5"],
google: ["gemini-3-pro", "gemini-3-flash"],
google: ["gemini-3.1-pro", "gemini-3-flash"],
openai: ["gpt-5.2", "gpt-5.3-codex"],
},
connected: ["anthropic", "google", "openai"],
@@ -73,7 +73,7 @@ describe("sisyphus-task", () => {
// when / #then
expect(category).toBeDefined()
expect(category.model).toBe("google/gemini-3-pro")
expect(category.model).toBe("google/gemini-3.1-pro")
expect(category.variant).toBe("high")
})
@@ -781,7 +781,7 @@ describe("sisyphus-task", () => {
// then
expect(result).not.toBeNull()
expect(result!.config.model).toBe("google/gemini-3-pro")
expect(result!.config.model).toBe("google/gemini-3.1-pro")
expect(result!.promptAppend).toContain("VISUAL/UI")
})
@@ -805,7 +805,7 @@ describe("sisyphus-task", () => {
const categoryName = "visual-engineering"
const userCategories = {
"visual-engineering": {
model: "google/gemini-3-pro",
model: "google/gemini-3.1-pro",
prompt_append: "Custom instructions here",
},
}
@@ -845,7 +845,7 @@ describe("sisyphus-task", () => {
const categoryName = "visual-engineering"
const userCategories = {
"visual-engineering": {
model: "google/gemini-3-pro",
model: "google/gemini-3.1-pro",
temperature: 0.3,
},
}
@@ -868,7 +868,7 @@ describe("sisyphus-task", () => {
// then - category's built-in model wins over inheritedModel
expect(result).not.toBeNull()
expect(result!.config.model).toBe("google/gemini-3-pro")
expect(result!.config.model).toBe("google/gemini-3.1-pro")
})
test("systemDefaultModel is used as fallback when custom category has no model", () => {
@@ -910,7 +910,7 @@ describe("sisyphus-task", () => {
// then
expect(result).not.toBeNull()
expect(result!.config.model).toBe("google/gemini-3-pro")
expect(result!.config.model).toBe("google/gemini-3.1-pro")
})
})
@@ -1738,7 +1738,7 @@ describe("sisyphus-task", () => {
const mockClient = {
app: { agents: async () => ({ data: [] }) },
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
model: { list: async () => [{ provider: "google", id: "gemini-3-pro" }] },
model: { list: async () => [{ provider: "google", id: "gemini-3.1-pro" }] },
session: {
get: async () => ({ data: { directory: "/project" } }),
create: async () => ({ data: { id: "ses_unstable_gemini" } }),
@@ -2001,7 +2001,7 @@ describe("sisyphus-task", () => {
const mockClient = {
app: { agents: async () => ({ data: [] }) },
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
model: { list: async () => [{ provider: "google", id: "gemini-3-pro" }] },
model: { list: async () => [{ provider: "google", id: "gemini-3.1-pro" }] },
session: {
get: async () => ({ data: { directory: "/project" } }),
create: async () => ({ data: { id: "ses_artistry_gemini" } }),
@@ -2028,7 +2028,7 @@ describe("sisyphus-task", () => {
abort: new AbortController().signal,
}
// when - artistry category (gemini-3-pro with high variant)
// when - artistry category (gemini-3.1-pro with high variant)
const result = await tool.execute(
{
description: "Test artistry forced background",
@@ -3026,9 +3026,9 @@ describe("sisyphus-task", () => {
// when resolveCategoryConfig is called
const resolved = resolveCategoryConfig(categoryName, { userCategories, inheritedModel, systemDefaultModel: SYSTEM_DEFAULT_MODEL })
// then should use category's built-in model (gemini-3-pro for visual-engineering)
// then should use category's built-in model (gemini-3.1-pro for visual-engineering)
expect(resolved).not.toBeNull()
expect(resolved!.model).toBe("google/gemini-3-pro")
expect(resolved!.model).toBe("google/gemini-3.1-pro")
})
test("systemDefaultModel is used when no other model is available", () => {
@@ -3522,7 +3522,7 @@ describe("sisyphus-task", () => {
)
// then - should resolve via AGENT_MODEL_REQUIREMENTS fallback chain for oracle
// oracle fallback chain: gpt-5.2 (openai) > gemini-3-pro (google) > claude-opus-4-6 (anthropic)
// oracle fallback chain: gpt-5.2 (openai) > gemini-3.1-pro (google) > claude-opus-4-6 (anthropic)
// Since openai is in connectedProviders, should resolve to openai/gpt-5.2
expect(promptBody.model).toBeDefined()
expect(promptBody.model.providerID).toBe("openai")
+9 -1
View File
@@ -142,7 +142,7 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
const runInBackground = args.run_in_background === true
const { content: skillContent, error: skillError } = await resolveSkillContent(args.load_skills, {
const { content: skillContent, contents: skillContents, error: skillError } = await resolveSkillContent(args.load_skills, {
gitMasterConfig: options.gitMasterConfig,
browserProvider: options.browserProvider,
disabledSkills: options.disabledSkills,
@@ -184,6 +184,7 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
let actualModel: string | undefined
let isUnstableAgent = false
let fallbackChain: import("../../shared/model-requirements").FallbackEntry[] | undefined
let maxPromptTokens: number | undefined
if (args.category) {
const resolution = await resolveCategoryExecution(args, options, inheritedModel, systemDefaultModel)
@@ -197,6 +198,7 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
actualModel = resolution.actualModel
isUnstableAgent = resolution.isUnstableAgent
fallbackChain = resolution.fallbackChain
maxPromptTokens = resolution.maxPromptTokens
const isRunInBackgroundExplicitlyFalse = args.run_in_background === false || args.run_in_background === "false" as unknown as boolean
@@ -213,8 +215,11 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
if (isUnstableAgent && isRunInBackgroundExplicitlyFalse) {
const systemContent = buildSystemContent({
skillContent,
skillContents,
categoryPromptAppend,
agentName: agentToUse,
maxPromptTokens,
model: categoryModel,
availableCategories,
availableSkills,
})
@@ -239,8 +244,11 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
const systemContent = buildSystemContent({
skillContent,
skillContents,
categoryPromptAppend,
agentName: agentToUse,
maxPromptTokens,
model: categoryModel,
availableCategories,
availableSkills,
})
+5
View File
@@ -72,7 +72,12 @@ export interface DelegateTaskToolOptions {
export interface BuildSystemContentInput {
skillContent?: string
skillContents?: string[]
categoryPromptAppend?: string
agentsContext?: string
planAgentPrepend?: string
maxPromptTokens?: number
model?: { providerID: string; modelID: string; variant?: string }
agentName?: string
availableCategories?: AvailableCategory[]
availableSkills?: AvailableSkill[]