feat(config): add configurable agent ordering
This commit is contained in:
@@ -14,6 +14,14 @@
|
||||
"default_run_agent": {
|
||||
"type": "string"
|
||||
},
|
||||
"agent_order": {
|
||||
"maxItems": 64,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"maxLength": 128
|
||||
}
|
||||
},
|
||||
"agent_definitions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
|
||||
@@ -161,7 +161,13 @@ Override built-in agent settings. Available agents: `sisyphus`, `hephaestus`, `p
|
||||
|
||||
Disable agents entirely: `{ "disabled_agents": ["oracle", "multimodal-looker"] }`
|
||||
|
||||
Core agents receive an injected runtime `order` field for deterministic Tab cycling in the UI: Sisyphus = 1, Hephaestus = 2, Prometheus = 3, Atlas = 4. This is not a user-configurable config key.
|
||||
Agent tab cycling defaults to Sisyphus, Hephaestus, Prometheus, Atlas. Override known agent ordering with `agent_order`; omitted core agents keep their default relative order. Unknown or duplicate names are ignored and reported with a config toast.
|
||||
|
||||
```json
|
||||
{
|
||||
"agent_order": ["hephaestus", "sisyphus", "prometheus", "atlas"]
|
||||
}
|
||||
```
|
||||
|
||||
#### Agent Options
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ When running inside tmux:
|
||||
- Watch multiple agents work in real-time
|
||||
- Each pane shows agent output live
|
||||
- Auto-cleanup when agents complete
|
||||
- **Stable agent ordering**: core-agent tab cycling is deterministic via injected runtime order field (Sisyphus: 1, Hephaestus: 2, Prometheus: 3, Atlas: 4)
|
||||
- **Stable agent ordering**: core-agent tab cycling defaults to Sisyphus, Hephaestus, Prometheus, Atlas, and can be customized with `agent_order`
|
||||
|
||||
Customize agent models, prompts, and permissions in `oh-my-opencode.jsonc`.
|
||||
|
||||
|
||||
@@ -38,3 +38,58 @@ describe("OhMyOpenCodeConfigSchema team_mode", () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("OhMyOpenCodeConfigSchema agent_order", () => {
|
||||
it("accepts string agent ordering when provided", () => {
|
||||
// given
|
||||
const rawConfig = {
|
||||
agent_order: ["hephaestus", "sisyphus", "prometheus", "atlas"],
|
||||
}
|
||||
|
||||
// when
|
||||
const result = OhMyOpenCodeConfigSchema.safeParse(rawConfig)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.agent_order).toEqual([
|
||||
"hephaestus",
|
||||
"sisyphus",
|
||||
"prometheus",
|
||||
"atlas",
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
it("allows agent_order omission", () => {
|
||||
// given
|
||||
const rawConfig = {}
|
||||
|
||||
// when
|
||||
const result = OhMyOpenCodeConfigSchema.safeParse(rawConfig)
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.agent_order).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it("rejects abusive agent_order string length and item count", () => {
|
||||
// given
|
||||
const tooLongName = "x".repeat(129)
|
||||
const tooManyNames = Array.from({ length: 65 }, (_, index) => `agent-${index}`)
|
||||
|
||||
// when
|
||||
const tooLongResult = OhMyOpenCodeConfigSchema.safeParse({
|
||||
agent_order: [tooLongName],
|
||||
})
|
||||
const tooManyResult = OhMyOpenCodeConfigSchema.safeParse({
|
||||
agent_order: tooManyNames,
|
||||
})
|
||||
|
||||
// then
|
||||
expect(tooLongResult.success).toBe(false)
|
||||
expect(tooManyResult.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -32,6 +32,8 @@ export const OhMyOpenCodeConfigSchema = z.object({
|
||||
new_task_system_enabled: z.boolean().optional(),
|
||||
/** Default agent name for `oh-my-opencode run` (env: OPENCODE_DEFAULT_AGENT) */
|
||||
default_run_agent: z.string().optional(),
|
||||
/** Preferred display order for known agents. Invalid names are ignored with a toast warning. */
|
||||
agent_order: z.array(z.string().max(128)).max(64).optional(),
|
||||
/** Paths to external agent definition files (.md or .json) */
|
||||
agent_definitions: AgentDefinitionsConfigSchema,
|
||||
disabled_mcps: z.array(AnyMcpNameSchema).optional(),
|
||||
|
||||
@@ -37,6 +37,8 @@ const mockCreateHooks = mock(() => ({
|
||||
const mockCreatePluginInterface = mock(() => ({}))
|
||||
const mockInitializeOpenClaw = mock(async () => {})
|
||||
const mockStartTmuxCheck = mock(() => {})
|
||||
const mockInstallAgentSortShim = mock(() => {})
|
||||
const mockSetAgentSortOrder = mock(() => {})
|
||||
|
||||
let pluginModule: (typeof import("./index"))["default"]
|
||||
|
||||
@@ -95,6 +97,11 @@ function installIndexModuleMocks(): void {
|
||||
})),
|
||||
}))
|
||||
|
||||
mock.module("./shared/agent-sort-shim", () => ({
|
||||
installAgentSortShim: mockInstallAgentSortShim,
|
||||
setAgentSortOrder: mockSetAgentSortOrder,
|
||||
}))
|
||||
|
||||
mock.module("./openclaw", () => ({
|
||||
initializeOpenClaw: mockInitializeOpenClaw,
|
||||
}))
|
||||
@@ -130,6 +137,8 @@ describe("oh-my-openagent plugin module", () => {
|
||||
mockCreatePluginInterface.mockClear()
|
||||
mockInitializeOpenClaw.mockClear()
|
||||
mockStartTmuxCheck.mockClear()
|
||||
mockInstallAgentSortShim.mockClear()
|
||||
mockSetAgentSortOrder.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
+2
-1
@@ -14,7 +14,7 @@ import { loadPluginConfig } from "./plugin-config"
|
||||
import { createModelCacheState } from "./plugin-state"
|
||||
import { createFirstMessageVariantGate } from "./shared/first-message-variant"
|
||||
import { injectServerAuthIntoClient, log, logLegacyPluginStartupWarning } from "./shared"
|
||||
import { installAgentSortShim } from "./shared/agent-sort-shim"
|
||||
import { installAgentSortShim, setAgentSortOrder } from "./shared/agent-sort-shim"
|
||||
import { detectExternalSkillPlugin, getSkillPluginConflictWarning } from "./shared/external-plugin-detector"
|
||||
import { startBackgroundCheck as startTmuxCheck } from "./tools/interactive-bash"
|
||||
|
||||
@@ -34,6 +34,7 @@ const serverPlugin: Plugin = async (input, _options): Promise<Hooks> => {
|
||||
injectServerAuthIntoClient(input.client)
|
||||
|
||||
const pluginConfig = loadPluginConfig(input.directory, input)
|
||||
setAgentSortOrder(pluginConfig.agent_order)
|
||||
|
||||
if (pluginConfig.openclaw) {
|
||||
await initializeOpenClaw(pluginConfig.openclaw)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { afterEach, describe, expect, it, mock, spyOn } from "bun:test";
|
||||
import { afterEach, describe, expect, it, mock } from "bun:test";
|
||||
import { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { mergeConfigs, parseConfigPartially } from "./plugin-config";
|
||||
import { loadConfigFromPath, mergeConfigs, parseConfigPartially } from "./plugin-config";
|
||||
import { OhMyOpenCodeConfigSchema, type OhMyOpenCodeConfig, type TeamModeConfig } from "./config";
|
||||
import { clearConfigLoadErrors, getConfigLoadErrors } from "./shared/config-errors";
|
||||
|
||||
const tempDirs: string[] = []
|
||||
type ConfigInput = Omit<Partial<OhMyOpenCodeConfig>, "team_mode"> & {
|
||||
@@ -20,6 +21,7 @@ async function importFreshPluginConfigModule(): Promise<typeof import("./plugin-
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
clearConfigLoadErrors()
|
||||
delete process.env.OPENCODE_CONFIG_DIR
|
||||
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
@@ -273,6 +275,35 @@ describe("parseConfigPartially", () => {
|
||||
expect(result!.agents).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should preserve valid agent_order when another section is invalid", () => {
|
||||
const rawConfig = {
|
||||
agent_order: ["hephaestus", "sisyphus", "prometheus", "atlas"],
|
||||
disabled_skills: [42],
|
||||
};
|
||||
|
||||
const result = parseConfigPartially(rawConfig);
|
||||
|
||||
expect(result?.agent_order).toEqual([
|
||||
"hephaestus",
|
||||
"sisyphus",
|
||||
"prometheus",
|
||||
"atlas",
|
||||
]);
|
||||
expect(result?.disabled_skills).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should skip abusive agent_order when another section is valid", () => {
|
||||
const rawConfig = {
|
||||
agent_order: ["x".repeat(129)],
|
||||
disabled_hooks: ["comment-checker"],
|
||||
};
|
||||
|
||||
const result = parseConfigPartially(rawConfig);
|
||||
|
||||
expect(result?.agent_order).toBeUndefined();
|
||||
expect(result?.disabled_hooks).toEqual(["comment-checker"]);
|
||||
});
|
||||
|
||||
it("should preserve valid agents when a non-agent section is invalid", () => {
|
||||
const rawConfig = {
|
||||
agents: {
|
||||
@@ -349,6 +380,51 @@ describe("parseConfigPartially", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadConfigFromPath agent_order warnings", () => {
|
||||
it("loads config and records warning for invalid agent_order entries", () => {
|
||||
// given
|
||||
const rootDir = mkdtempSync(join(tmpdir(), "agent-order-warning-"))
|
||||
tempDirs.push(rootDir)
|
||||
const configPath = join(rootDir, "oh-my-openagent.json")
|
||||
writeJsonFile(configPath, {
|
||||
agent_order: ["hephaestus", "not-real", "sisyphus", "hephaestus"],
|
||||
})
|
||||
|
||||
// when
|
||||
const result = loadConfigFromPath(configPath, {})
|
||||
|
||||
// then
|
||||
expect(result?.agent_order).toEqual(["hephaestus", "not-real", "sisyphus", "hephaestus"])
|
||||
expect(getConfigLoadErrors()).toEqual([
|
||||
{
|
||||
path: configPath,
|
||||
error: 'agent_order warning - unknown agent names ignored: "not-real"; duplicate agent names ignored: "hephaestus"',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("sanitizes and caps invalid agent_order values before recording warnings", () => {
|
||||
// given
|
||||
const rootDir = mkdtempSync(join(tmpdir(), "agent-order-sanitize-"))
|
||||
tempDirs.push(rootDir)
|
||||
const configPath = join(rootDir, "oh-my-openagent.json")
|
||||
writeJsonFile(configPath, {
|
||||
agent_order: [
|
||||
"\u001B[31mbad\u001B[0m",
|
||||
...Array.from({ length: 11 }, (_, index) => `missing-${index}`),
|
||||
],
|
||||
})
|
||||
|
||||
// when
|
||||
loadConfigFromPath(configPath, {})
|
||||
|
||||
// then
|
||||
expect(getConfigLoadErrors()[0]?.error).toBe(
|
||||
'agent_order warning - unknown agent names ignored: "[31mbad[0m", "missing-0", "missing-1", "missing-2", "missing-3", "missing-4", "missing-5", "missing-6", "missing-7", "missing-8", (+2 more)',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("loadPluginConfig", () => {
|
||||
it("should only honor mcp_env_allowlist from user config", async () => {
|
||||
// given
|
||||
|
||||
@@ -16,6 +16,50 @@ import {
|
||||
} from "./shared";
|
||||
import { migrateLegacyConfigFile } from "./shared/migrate-legacy-config-file";
|
||||
import { CONFIG_BASENAME, LEGACY_CONFIG_BASENAME } from "./shared/plugin-identity";
|
||||
import { validateAgentOrder } from "./shared/agent-ordering";
|
||||
|
||||
const CONTROL_CHARACTERS_REGEX = /[\u0000-\u001F\u007F-\u009F\u202A-\u202E\u2066-\u2069]/g;
|
||||
const MAX_AGENT_ORDER_WARNING_VALUES = 10;
|
||||
const MAX_AGENT_ORDER_WARNING_VALUE_LENGTH = 80;
|
||||
|
||||
function formatAgentOrderWarningValues(values: readonly string[]): string {
|
||||
const displayedValues = values.slice(0, MAX_AGENT_ORDER_WARNING_VALUES).map((value) => {
|
||||
const sanitized = value.replace(CONTROL_CHARACTERS_REGEX, "");
|
||||
const truncated = sanitized.length > MAX_AGENT_ORDER_WARNING_VALUE_LENGTH
|
||||
? `${sanitized.slice(0, MAX_AGENT_ORDER_WARNING_VALUE_LENGTH)}...`
|
||||
: sanitized;
|
||||
return JSON.stringify(truncated);
|
||||
});
|
||||
|
||||
const remaining = values.length - displayedValues.length;
|
||||
if (remaining > 0) {
|
||||
displayedValues.push(`(+${remaining} more)`);
|
||||
}
|
||||
|
||||
return displayedValues.join(", ");
|
||||
}
|
||||
|
||||
function addAgentOrderWarnings(configPath: string, agentOrder: string[] | undefined): void {
|
||||
if (!agentOrder) return;
|
||||
|
||||
const validation = validateAgentOrder(agentOrder);
|
||||
const messages: string[] = [];
|
||||
|
||||
if (validation.invalid.length > 0) {
|
||||
messages.push(`unknown agent names ignored: ${formatAgentOrderWarningValues(validation.invalid)}`);
|
||||
}
|
||||
|
||||
if (validation.duplicates.length > 0) {
|
||||
messages.push(`duplicate agent names ignored: ${formatAgentOrderWarningValues(validation.duplicates)}`);
|
||||
}
|
||||
|
||||
if (messages.length === 0) return;
|
||||
|
||||
addConfigLoadError({
|
||||
path: configPath,
|
||||
error: `agent_order warning - ${messages.join("; ")}`,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveHomeDirectory(): string {
|
||||
// Read env vars directly to bypass os.homedir() caching. Bun caches the
|
||||
@@ -134,6 +178,7 @@ export function loadConfigFromPath(
|
||||
const result = OhMyOpenCodeConfigSchema.safeParse(rawConfig);
|
||||
|
||||
if (result.success) {
|
||||
addAgentOrderWarnings(configPath, result.data.agent_order);
|
||||
log(`Config loaded from ${configPath}`, { agents: result.data.agents });
|
||||
return result.data;
|
||||
}
|
||||
@@ -149,6 +194,7 @@ export function loadConfigFromPath(
|
||||
|
||||
const partialResult = parseConfigPartially(rawConfig);
|
||||
if (partialResult) {
|
||||
addAgentOrderWarnings(configPath, partialResult.agent_order);
|
||||
log(`Partial config loaded from ${configPath}`, { agents: partialResult.agents });
|
||||
return partialResult;
|
||||
}
|
||||
|
||||
@@ -4,11 +4,12 @@
|
||||
|
||||
## CRITICAL: AGENT ORDERING
|
||||
|
||||
The canonical agent order is **sisyphus → hephaestus → prometheus → atlas**.
|
||||
The default agent order is **sisyphus → hephaestus → prometheus → atlas**. User config may override it with `agent_order`; omitted core agents fall back to this default order.
|
||||
|
||||
This order is enforced via two cooperating mechanisms:
|
||||
1. `CANONICAL_CORE_AGENT_ORDER` in `agent-priority-order.ts` controls object key insertion order in the agent map produced by `applyAgentConfig`.
|
||||
2. `installAgentSortShim()` in `src/shared/agent-sort-shim.ts` narrows `Array.prototype.toSorted` and `Array.prototype.sort` so that whenever the sorted array contains two or more agent objects whose `.name` matches a canonical core display name, OpenCode's `Agent.list()` (and any other sort site) returns the canonical order. The shim is installed once at plugin entry, before any agent registration.
|
||||
1. `DEFAULT_AGENT_ORDER` in `src/shared/agent-ordering.ts` supplies the fallback order used when `agent_order` is absent or incomplete.
|
||||
2. `reorderAgentsByPriority()` in `agent-priority-order.ts` controls object key insertion order in the agent map produced by `applyAgentConfig`.
|
||||
3. `installAgentSortShim()` in `src/shared/agent-sort-shim.ts` narrows `Array.prototype.toSorted` and `Array.prototype.sort` so that whenever the sorted array contains two or more ranked agent objects, OpenCode's `Agent.list()` (and any other sort site) returns the active configured/default order. The shim is installed once at plugin entry, before any agent registration, and its rank map is updated after plugin config loads.
|
||||
|
||||
### Why a Sort Shim
|
||||
|
||||
@@ -18,7 +19,7 @@ OpenCode 1.4.x sorts agents purely by `agent.name` via Remeda `sortBy`, which us
|
||||
- Removing the prefix and relying on insertion order alone falls back to alphabetical Atlas → Hephaestus → Prometheus → Sisyphus.
|
||||
|
||||
The sort shim resolves this by intercepting only the narrow case it cares about, with strict activation guards to prevent collateral damage from a global prototype patch:
|
||||
- The activation predicate (`isAgentArray`) requires `arr.length >= 2`, every element is a non-null object with a string `.name`, and at least 2 elements have a `.name` matching one of the four canonical core display names. This rejects mixed-type arrays (numbers, strings, plain objects without `.name`) so unrelated `.sort()` / `.toSorted()` calls execute native semantics.
|
||||
- The activation predicate (`isAgentArray`) requires `arr.length >= 2`, every element is a non-null object with a string `.name`, and at least 2 elements have a `.name` ranked by the active order. This rejects mixed-type arrays (numbers, strings, plain objects without `.name`) so unrelated `.sort()` / `.toSorted()` calls execute native semantics.
|
||||
- The comparator never throws on mixed input — it defensively extracts `.name` and falls back to the user-supplied `compareFn`.
|
||||
- `installAgentSortShim()` is idempotent.
|
||||
|
||||
@@ -34,7 +35,7 @@ Agent ordering has caused 15+ commits, 8+ PRs, and multiple reverts. Notable mil
|
||||
DO NOT introduce:
|
||||
- ZWSP, U+2060, U+00AD, ANSI escape, or any other invisible / control character in agent names, display names, or object keys.
|
||||
- ASCII spaces or other visible sort prefixes on agent names.
|
||||
- Alternative ordering constants outside `CANONICAL_CORE_AGENT_ORDER`.
|
||||
- Alternative ordering constants outside `DEFAULT_AGENT_ORDER` / `CANONICAL_CORE_AGENT_ORDER`, or ordering code that bypasses `validateAgentOrder`.
|
||||
- Object.entries() iteration-order dependencies.
|
||||
- Agent name string comparisons that skip `getAgentConfigKey` / `stripInvisibleAgentCharacters` (legacy ZWSP-baked data must keep resolving).
|
||||
|
||||
|
||||
@@ -395,6 +395,7 @@ export async function applyAgentConfig(params: {
|
||||
);
|
||||
params.config.agent = reorderAgentsByPriority(
|
||||
params.config.agent as Record<string, unknown>,
|
||||
params.pluginConfig.agent_order,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -65,6 +65,48 @@ describe("agent-priority-order", () => {
|
||||
expect(keys[3]).toBe(atlas)
|
||||
})
|
||||
|
||||
test("#when custom agent order is provided #then follows configured core ordering", () => {
|
||||
// given
|
||||
const agents: Record<string, unknown> = {
|
||||
[atlas]: { name: "atlas" },
|
||||
[prometheus]: { name: "prometheus" },
|
||||
[hephaestus]: { name: "hephaestus" },
|
||||
[sisyphus]: { name: "sisyphus" },
|
||||
}
|
||||
|
||||
// when
|
||||
const result = reorderAgentsByPriority(agents, [
|
||||
"hephaestus",
|
||||
"sisyphus",
|
||||
"prometheus",
|
||||
"atlas",
|
||||
])
|
||||
|
||||
// then
|
||||
expect(Object.keys(result)).toEqual([hephaestus, sisyphus, prometheus, atlas])
|
||||
})
|
||||
|
||||
test("#when custom agent order contains invalid entries #then ignores them and keeps valid/default ordering", () => {
|
||||
// given
|
||||
const agents: Record<string, unknown> = {
|
||||
[atlas]: { name: "atlas" },
|
||||
[prometheus]: { name: "prometheus" },
|
||||
[hephaestus]: { name: "hephaestus" },
|
||||
[sisyphus]: { name: "sisyphus" },
|
||||
}
|
||||
|
||||
// when
|
||||
const result = reorderAgentsByPriority(agents, [
|
||||
"not-real",
|
||||
"atlas",
|
||||
"hephaestus",
|
||||
"atlas",
|
||||
])
|
||||
|
||||
// then
|
||||
expect(Object.keys(result)).toEqual([atlas, hephaestus, sisyphus, prometheus])
|
||||
})
|
||||
|
||||
test("#when core agents mixed with non-core #then core agents come first in canonical order", () => {
|
||||
// given: mixed order with non-core agents interleaved
|
||||
const agents: Record<string, unknown> = {
|
||||
@@ -199,6 +241,21 @@ describe("agent-priority-order", () => {
|
||||
expect(result[atlas]).toEqual({ name: "atlas", mode: "primary", order: 4 })
|
||||
})
|
||||
|
||||
test("#when custom agent order is provided #then injects matching order fields", () => {
|
||||
// given
|
||||
const agents: Record<string, unknown> = {
|
||||
[sisyphus]: { name: "sisyphus", mode: "primary" },
|
||||
[hephaestus]: { name: "hephaestus", mode: "primary" },
|
||||
}
|
||||
|
||||
// when
|
||||
const result = reorderAgentsByPriority(agents, ["hephaestus", "sisyphus"])
|
||||
|
||||
// then
|
||||
expect(result[hephaestus]).toEqual({ name: "hephaestus", mode: "primary", order: 1 })
|
||||
expect(result[sisyphus]).toEqual({ name: "sisyphus", mode: "primary", order: 2 })
|
||||
})
|
||||
|
||||
test("#when core agent is non-object #then leaves value unchanged", () => {
|
||||
// given
|
||||
const agents: Record<string, unknown> = {
|
||||
|
||||
@@ -1,35 +1,16 @@
|
||||
import { getAgentListDisplayName } from "../shared/agent-display-names"
|
||||
import { DEFAULT_AGENT_ORDER, resolveAgentOrderDisplayNames } from "../shared/agent-ordering"
|
||||
|
||||
/**
|
||||
* CRITICAL: This is the ONLY source of truth for core agent ordering.
|
||||
* The order is: sisyphus → hephaestus → prometheus → atlas
|
||||
* Default source of truth for core agent ordering.
|
||||
* The default order is: sisyphus → hephaestus → prometheus → atlas.
|
||||
*
|
||||
* DO NOT CHANGE THIS ORDER. Any PR attempting to modify this order
|
||||
* or introduce alternative ordering mechanisms (ZWSP prefixes, sort
|
||||
* shims, etc.) will be rejected.
|
||||
* User config may override the runtime order through `agent_order`; missing
|
||||
* core agents still fall back to this default order. Do not reintroduce sort
|
||||
* key prefixes or a second ordering constant.
|
||||
*
|
||||
* See: src/plugin-handlers/AGENTS.md for architectural context.
|
||||
*/
|
||||
export const CANONICAL_CORE_AGENT_ORDER = [
|
||||
"sisyphus",
|
||||
"hephaestus",
|
||||
"prometheus",
|
||||
"atlas",
|
||||
] as const
|
||||
|
||||
type CoreAgentName = (typeof CANONICAL_CORE_AGENT_ORDER)[number]
|
||||
|
||||
const CORE_AGENT_ORDER: ReadonlyArray<{
|
||||
configKey: CoreAgentName
|
||||
displayName: string
|
||||
order: number
|
||||
}> = CANONICAL_CORE_AGENT_ORDER.map((configKey, index) => ({
|
||||
configKey,
|
||||
displayName: getAgentListDisplayName(configKey),
|
||||
order: index + 1,
|
||||
}))
|
||||
|
||||
const CORE_DISPLAY_NAMES = new Set(CORE_AGENT_ORDER.map((a) => a.displayName))
|
||||
export const CANONICAL_CORE_AGENT_ORDER = DEFAULT_AGENT_ORDER
|
||||
|
||||
function injectOrderField(agentConfig: unknown, order: number): unknown {
|
||||
if (typeof agentConfig === "object" && agentConfig !== null) {
|
||||
@@ -40,13 +21,15 @@ function injectOrderField(agentConfig: unknown, order: number): unknown {
|
||||
|
||||
export function reorderAgentsByPriority(
|
||||
agents: Record<string, unknown>,
|
||||
agentOrder?: readonly string[],
|
||||
): Record<string, unknown> {
|
||||
const ordered: Record<string, unknown> = {}
|
||||
const seen = new Set<string>()
|
||||
const orderedDisplayNames = resolveAgentOrderDisplayNames(agentOrder)
|
||||
|
||||
for (const { displayName, order } of CORE_AGENT_ORDER) {
|
||||
for (const [index, displayName] of orderedDisplayNames.entries()) {
|
||||
if (Object.prototype.hasOwnProperty.call(agents, displayName)) {
|
||||
ordered[displayName] = injectOrderField(agents[displayName], order)
|
||||
ordered[displayName] = injectOrderField(agents[displayName], index + 1)
|
||||
seen.add(displayName)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { AGENT_DISPLAY_NAMES, getAgentConfigKey, getAgentListDisplayName } from "./agent-display-names"
|
||||
|
||||
export const DEFAULT_AGENT_ORDER = [
|
||||
"sisyphus",
|
||||
"hephaestus",
|
||||
"prometheus",
|
||||
"atlas",
|
||||
] as const
|
||||
|
||||
export type AgentOrderValidation = {
|
||||
order: string[]
|
||||
invalid: string[]
|
||||
duplicates: string[]
|
||||
}
|
||||
|
||||
const KNOWN_AGENT_KEYS = new Set(Object.keys(AGENT_DISPLAY_NAMES))
|
||||
|
||||
function appendUnique(target: string[], value: string): void {
|
||||
if (!target.includes(value)) {
|
||||
target.push(value)
|
||||
}
|
||||
}
|
||||
|
||||
export function validateAgentOrder(agentOrder: readonly string[] | undefined): AgentOrderValidation {
|
||||
const order: string[] = []
|
||||
const invalid: string[] = []
|
||||
const duplicates: string[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
for (const rawName of agentOrder ?? []) {
|
||||
const trimmed = rawName.trim()
|
||||
if (trimmed.length === 0) {
|
||||
invalid.push(rawName)
|
||||
continue
|
||||
}
|
||||
|
||||
const configKey = getAgentConfigKey(trimmed)
|
||||
if (!KNOWN_AGENT_KEYS.has(configKey)) {
|
||||
invalid.push(rawName)
|
||||
continue
|
||||
}
|
||||
|
||||
if (seen.has(configKey)) {
|
||||
duplicates.push(rawName)
|
||||
continue
|
||||
}
|
||||
|
||||
seen.add(configKey)
|
||||
order.push(configKey)
|
||||
}
|
||||
|
||||
for (const configKey of DEFAULT_AGENT_ORDER) {
|
||||
appendUnique(order, configKey)
|
||||
}
|
||||
|
||||
return { order, invalid, duplicates }
|
||||
}
|
||||
|
||||
export function resolveAgentOrderDisplayNames(agentOrder: readonly string[] | undefined): string[] {
|
||||
return validateAgentOrder(agentOrder).order.map((configKey) => getAgentListDisplayName(configKey))
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { beforeAll, describe, expect, test } from "bun:test"
|
||||
import { afterEach, beforeAll, describe, expect, test } from "bun:test"
|
||||
|
||||
import { installAgentSortShim } from "./agent-sort-shim"
|
||||
import { installAgentSortShim, setAgentSortOrder } from "./agent-sort-shim"
|
||||
import { AGENT_DISPLAY_NAMES } from "./agent-display-names"
|
||||
|
||||
type AgentListItem = {
|
||||
@@ -10,15 +10,26 @@ type AgentListItem = {
|
||||
default_agent?: boolean
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Array<T> {
|
||||
toSorted(compareFn?: (a: T, b: T) => number): T[]
|
||||
}
|
||||
}
|
||||
|
||||
describe("agent-sort-shim", () => {
|
||||
beforeAll(() => {
|
||||
installAgentSortShim()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setAgentSortOrder(undefined)
|
||||
})
|
||||
|
||||
describe("#given an array of all 4 core agent objects in random order", () => {
|
||||
describe("#when toSorted with alphabetical compareFn", () => {
|
||||
test("#then returns canonical sisyphus->hephaestus->prometheus->atlas order", () => {
|
||||
// given
|
||||
setAgentSortOrder(undefined)
|
||||
const sisyphus = { name: "Sisyphus - Ultraworker" }
|
||||
const hephaestus = { name: "Hephaestus - Deep Agent" }
|
||||
const prometheus = { name: "Prometheus - Plan Builder" }
|
||||
@@ -31,6 +42,22 @@ describe("agent-sort-shim", () => {
|
||||
// then
|
||||
expect(result).toEqual([sisyphus, hephaestus, prometheus, atlas])
|
||||
})
|
||||
|
||||
test("#then follows configured core agent order", () => {
|
||||
// given
|
||||
setAgentSortOrder(["hephaestus", "sisyphus", "prometheus", "atlas"])
|
||||
const sisyphus = { name: "Sisyphus - Ultraworker" }
|
||||
const hephaestus = { name: "Hephaestus - Deep Agent" }
|
||||
const prometheus = { name: "Prometheus - Plan Builder" }
|
||||
const atlas = { name: "Atlas - Plan Executor" }
|
||||
const input = [atlas, prometheus, hephaestus, sisyphus]
|
||||
|
||||
// when
|
||||
const result = input.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
|
||||
// then
|
||||
expect(result).toEqual([hephaestus, sisyphus, prometheus, atlas])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
*
|
||||
* OpenCode 1.4.x ignores the agent `order` field (sst/opencode#19127) and
|
||||
* sorts the agent list by `agent.name` via Remeda `sortBy(x => x.name, "asc")`
|
||||
* at packages/opencode/src/agent/agent.ts. Without intervention, the four
|
||||
* core agents collapse into Atlas -> Hephaestus -> Prometheus -> Sisyphus,
|
||||
* which inverts the canonical sisyphus -> hephaestus -> prometheus -> atlas
|
||||
* order this project ships.
|
||||
* at packages/opencode/src/agent/agent.ts. Without intervention, core agents
|
||||
* collapse into name order, which can invert the default sisyphus -> hephaestus
|
||||
* -> prometheus -> atlas order or a user's configured `agent_order`.
|
||||
*
|
||||
* Earlier attempts to bias the sort key with invisible characters (ZWSP,
|
||||
* U+2060 WORD JOINER, U+00AD SOFT HYPHEN, ANSI escape) caused visible-gap
|
||||
@@ -17,22 +16,21 @@
|
||||
* 1. `isAgentArray` rejects any array element that is null, non-object, or
|
||||
* lacks a string `name`, eliminating the throw-on-mixed-array failure
|
||||
* mode that closed the original PR.
|
||||
* 2. The activation predicate requires >= 2 elements whose `.name` is one
|
||||
* of the four canonical core display names, so unrelated `.sort()` and
|
||||
* `.toSorted()` calls (string arrays, number arrays, generic objects)
|
||||
* execute native behavior unchanged.
|
||||
* 2. The activation predicate requires >= 2 elements whose `.name` is ranked
|
||||
* by the active agent order, so unrelated `.sort()` and `.toSorted()` calls
|
||||
* (string arrays, number arrays, generic objects) execute native behavior
|
||||
* unchanged.
|
||||
*
|
||||
* Remove this shim once OpenCode honors the agent `order` field
|
||||
* (sst/opencode#19127).
|
||||
*/
|
||||
|
||||
import { CANONICAL_CORE_AGENT_ORDER } from "../plugin-handlers/agent-priority-order"
|
||||
import { AGENT_DISPLAY_NAMES } from "./agent-display-names"
|
||||
import { DEFAULT_AGENT_ORDER, resolveAgentOrderDisplayNames } from "./agent-ordering"
|
||||
import { getAgentListDisplayName } from "./agent-display-names"
|
||||
|
||||
const AGENT_RANK: ReadonlyMap<string, number> = new Map(
|
||||
CANONICAL_CORE_AGENT_ORDER.map(
|
||||
(configKey, index): [string, number] => [AGENT_DISPLAY_NAMES[configKey], index + 1],
|
||||
),
|
||||
let agentRank: ReadonlyMap<string, number> = createAgentRank(undefined)
|
||||
const AGENT_ARRAY_SENTINELS = new Set(
|
||||
DEFAULT_AGENT_ORDER.map((configKey) => getAgentListDisplayName(configKey)),
|
||||
)
|
||||
|
||||
const UNRANKED = Number.MAX_SAFE_INTEGER
|
||||
@@ -51,7 +49,7 @@ function isAgentArray(arr: ReadonlyArray<unknown>): boolean {
|
||||
if (element === null || typeof element !== "object") return false
|
||||
const name = (element as { name?: unknown }).name
|
||||
if (typeof name !== "string") return false
|
||||
if (AGENT_RANK.has(name)) rankedCount++
|
||||
if (AGENT_ARRAY_SENTINELS.has(name)) rankedCount++
|
||||
}
|
||||
|
||||
return rankedCount >= 2
|
||||
@@ -62,8 +60,8 @@ function agentComparator(
|
||||
b: unknown,
|
||||
fallback: ((a: unknown, b: unknown) => number) | undefined,
|
||||
): number {
|
||||
const aRank = AGENT_RANK.get(extractAgentName(a)) ?? UNRANKED
|
||||
const bRank = AGENT_RANK.get(extractAgentName(b)) ?? UNRANKED
|
||||
const aRank = agentRank.get(extractAgentName(a)) ?? UNRANKED
|
||||
const bRank = agentRank.get(extractAgentName(b)) ?? UNRANKED
|
||||
|
||||
if (aRank !== bRank) return aRank - bRank
|
||||
if (fallback) return fallback(a, b)
|
||||
@@ -72,6 +70,18 @@ function agentComparator(
|
||||
|
||||
let installed = false
|
||||
|
||||
function createAgentRank(agentOrder: readonly string[] | undefined): ReadonlyMap<string, number> {
|
||||
return new Map(
|
||||
resolveAgentOrderDisplayNames(agentOrder).map(
|
||||
(displayName, index): [string, number] => [displayName, index + 1],
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export function setAgentSortOrder(agentOrder: readonly string[] | undefined): void {
|
||||
agentRank = createAgentRank(agentOrder)
|
||||
}
|
||||
|
||||
export function installAgentSortShim(): void {
|
||||
if (installed) return
|
||||
|
||||
|
||||
Reference in New Issue
Block a user