From 129b4cccdeb9e7201058a7a76438b11c31ec595a Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Tue, 27 Oct 2026 12:46:23 +0000 Subject: [PATCH 1/2] feat(cli): give each agent its role's model at a provider agent-models.json maps each role to a tier and each tier to a model and effort per provider. aidd translate --provider applies it through each host's agent function, and make reload passes the provider scripts/reload-agents.json names per host. --- .../2026_10_14_sub_agents/verification.md | 4 +- .../domain/formats/agent-frontmatter-strip.ts | 59 ---------- .../tools/domain/formats/agent-models.json | 38 ++++++ .../tools/domain/formats/agent-models.ts | 67 +++++++++++ cli/src/presentation/commands/translate.ts | 8 ++ cli/src/runtime/wiring/translate.ts | 11 +- .../agent-frontmatter-strip.unit.test.ts | 110 ------------------ .../domain/formats/agent-models.unit.test.ts | 88 ++++++++++++++ cli/tests/golden/snapshots/help/surface.json | 2 +- .../translate-wiring.integration.test.ts | 5 + docs/SUBAGENTS.md | 21 +++- scripts/__tests__/dev-sync.test.js | 24 ++++ scripts/dev-sync.sh | 42 ++++--- scripts/reload-agents.json | 5 + 14 files changed, 292 insertions(+), 192 deletions(-) delete mode 100644 cli/src/contexts/tools/domain/formats/agent-frontmatter-strip.ts create mode 100644 cli/src/contexts/tools/domain/formats/agent-models.json create mode 100644 cli/src/contexts/tools/domain/formats/agent-models.ts delete mode 100644 cli/tests/contexts/tools/domain/formats/agent-frontmatter-strip.unit.test.ts create mode 100644 cli/tests/contexts/tools/domain/formats/agent-models.unit.test.ts create mode 100644 scripts/reload-agents.json diff --git a/aidd_docs/tasks/2026_10/2026_10_14_sub_agents/verification.md b/aidd_docs/tasks/2026_10/2026_10_14_sub_agents/verification.md index d2d257f83..2f9105889 100644 --- a/aidd_docs/tasks/2026_10/2026_10_14_sub_agents/verification.md +++ b/aidd_docs/tasks/2026_10/2026_10_14_sub_agents/verification.md @@ -6,11 +6,13 @@ Behavior and sources are in [the agent guide](../../../../docs/SUBAGENTS.md). - **Agents:** explorer, executor, validator and reviewer ship in `plugins/aidd-orchestrator/agents/`, installed with the plugin. No tool list is declared: every host gives an agent all tools by default. - **Translation:** each host receives only the keys its documentation lists: name or file path as the ID, description, model, and Claude's `effort` under the host's own key. Each host has one agent frontmatter function, shared by the plugin install and its built distributions. -- **Out of scope:** per-provider models and a model for the main session. +- **Models:** `aidd translate --provider` pins each role's model and effort from `agent-models.json`; `make reload` passes the provider `scripts/reload-agents.json` names. +- **Out of scope:** the `aidd agents` command, and a model for the main session. ## Checks - **Claude Code:** the four agents load and run from a project-scoped plugin install. - **OpenCode:** the four agents run from the flat distribution through `opencode run --standalone --agent ` with an OpenAI model, each answering after a `read` call. - **Codex:** the four agents load from the flat distribution in a trusted project; `codex exec` spawned each, and its session records the agent's role and instructions. +- **Provider models:** with `--provider openai`, OpenCode loaded the executor as `{id: gpt-6-luna, providerID: openai, variant: medium}` (`opencode debug agents`), and a Codex session spawned the explorer on `gpt-6-luna` while its parent ran `gpt-6.1-sol`. - **Copilot, Cursor, Kilo:** no host binary in the test environment; build output covered by unit and golden tests only. diff --git a/cli/src/contexts/tools/domain/formats/agent-frontmatter-strip.ts b/cli/src/contexts/tools/domain/formats/agent-frontmatter-strip.ts deleted file mode 100644 index a963fc32b..000000000 --- a/cli/src/contexts/tools/domain/formats/agent-frontmatter-strip.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** Copilot-supported frontmatter keys for agent files. Order is preserved on serialization, - * for deterministic output. */ -export const COPILOT_AGENT_FRONTMATTER_KEYS: readonly [ - "name", - "description", - "model", - "reasoningEffort", - "include-custom-instructions", -] = ["name", "description", "model", "reasoningEffort", "include-custom-instructions"]; - -/** Cursor-supported frontmatter keys: Cursor documents only name, description and model — - * never tools or color. */ -export const CURSOR_AGENT_FRONTMATTER_KEYS: readonly ["name", "description", "model"] = [ - "name", - "description", - "model", -]; - -/** Keys of a host whose agent ID is its file path (OpenCode, Kilo): no `name`. */ -const PATH_ID_AGENT_FRONTMATTER_KEYS: readonly ["description", "model"] = ["description", "model"]; - -/** Pick only the given keys, preserving their order. A key whose value is undefined is - * omitted. */ -export function pickFrontmatterKeys( - fm: Record, - keys: readonly string[] -): Record { - const result: Record = {}; - for (const key of keys) { - if (fm[key] !== undefined) { - result[key] = fm[key]; - } - } - return result; -} - -/** Copilot reads Claude's `effort` as `reasoningEffort`, and gives a subagent the repository - * instructions only when `include-custom-instructions` is set. Lossy, so there is no inverse. */ -export function stripCopilotAgentFrontmatter(fm: Record): Record { - return pickFrontmatterKeys( - { - ...fm, - reasoningEffort: fm.effort, - "include-custom-instructions": fm["include-custom-instructions"] ?? true, - }, - COPILOT_AGENT_FRONTMATTER_KEYS - ); -} - -/** Only the Cursor-supported keys. Tools, color and argument-hint are discarded. */ -export function stripCursorAgentFrontmatter(fm: Record): Record { - return pickFrontmatterKeys(fm, CURSOR_AGENT_FRONTMATTER_KEYS); -} - -/** OpenCode and Kilo name an agent after its file and give it every tool by default; Kilo - * passes any unknown key to the model provider, so nothing else is carried. */ -export function stripPathIdAgentFrontmatter(fm: Record): Record { - return pickFrontmatterKeys(fm, PATH_ID_AGENT_FRONTMATTER_KEYS); -} diff --git a/cli/src/contexts/tools/domain/formats/agent-models.json b/cli/src/contexts/tools/domain/formats/agent-models.json new file mode 100644 index 000000000..8b04e3476 --- /dev/null +++ b/cli/src/contexts/tools/domain/formats/agent-models.json @@ -0,0 +1,38 @@ +{ + "roles": { + "explorer": "light", + "executor": "light", + "validator": "light", + "reviewer": "standard" + }, + "providers": { + "openai": { + "standard": { "model": "gpt-6.1-sol", "effort": "medium" }, + "light": { "model": "gpt-6-luna", "effort": "medium" } + }, + "anthropic": { + "standard": { + "model": "claude-sonnet-5-5", + "effort": "medium", + "nativeModels": { "copilot": "claude-sonnet-5.5" } + }, + "light": { + "model": "claude-haiku-5-5", + "effort": "medium", + "nativeModels": { "copilot": "claude-haiku-5.5" } + } + }, + "mistral": { + "standard": { "model": "mistral-medium-latest", "effort": "high" }, + "light": { "model": "mistral-small-latest", "effort": "high" } + } + }, + "hosts": { + "claude": { "providers": ["anthropic"] }, + "codex": { "providers": ["openai"] }, + "copilot": { "providers": ["openai", "anthropic"] }, + "opencode": { "providers": ["openai", "anthropic", "mistral"], "providerPrefix": true }, + "cursor": { "providers": ["anthropic"] }, + "kilo": { "providers": ["openai", "anthropic", "mistral"], "providerPrefix": true } + } +} diff --git a/cli/src/contexts/tools/domain/formats/agent-models.ts b/cli/src/contexts/tools/domain/formats/agent-models.ts new file mode 100644 index 000000000..32de6dce2 --- /dev/null +++ b/cli/src/contexts/tools/domain/formats/agent-models.ts @@ -0,0 +1,67 @@ +import { parseFrontmatter, serializeFrontmatter } from "../../../../kernel/markdown.js"; +import type { ToolBuildContract } from "../build-contract.js"; +import models from "./agent-models.json" with { type: "json" }; + +type Host = keyof typeof models.hosts; +type Provider = keyof typeof models.providers; + +interface ModelProfile { + readonly model: string; + readonly effort: string; + readonly nativeModels?: Readonly>; +} + +function supportedHost(host: string): Host { + if (!Object.hasOwn(models.hosts, host)) { + throw new Error(`No agent provider is supported for "${host}".`); + } + return host as Host; +} + +function supportedProvider(host: Host, provider: string): Provider { + const providers: readonly string[] = models.hosts[host].providers; + if (!providers.includes(provider)) { + throw new Error( + `Provider "${provider}" is not supported for "${host}". Use: ${providers.join(", ")}.` + ); + } + return provider as Provider; +} + +/** The model and effort an agent's role has at the provider, in the host's model ids. */ +function roleModel(host: Host, provider: Provider, role: string): ModelProfile | undefined { + const tier = Object.entries(models.roles).find(([name]) => name === role)?.[1]; + const profile: ModelProfile | undefined = Object.entries(models.providers[provider]).find( + ([name]) => name === tier + )?.[1]; + if (profile === undefined) return undefined; + const prefix = "providerPrefix" in models.hosts[host] ? `${provider}/` : ""; + return { + model: `${prefix}${profile.nativeModels?.[host] ?? profile.model}`, + effort: profile.effort, + }; +} + +/** Gives every agent whose role `agent-models.json` lists its model and effort at the + * provider, before the host writes it. A model the agent declares itself is kept. */ +export function withAgentModels( + contract: ToolBuildContract, + host: string, + provider: string +): ToolBuildContract { + const target = supportedHost(host); + const chosen = supportedProvider(target, provider); + const agents = contract.artifacts.agents; + if (!agents.supported) return contract; + const write = agents.transform ?? ((content: string) => content); + const transform = (content: string, plugin: string, fileName: string): string => { + const { frontmatter, body } = parseFrontmatter(content); + const profile = roleModel(target, chosen, fileName.replace(/\.md$/, "")); + if (profile === undefined || frontmatter.model !== undefined) { + return write(content, plugin, fileName); + } + const modelled = serializeFrontmatter({ ...frontmatter, ...profile }, body); + return write(modelled, plugin, fileName); + }; + return { ...contract, artifacts: { ...contract.artifacts, agents: { ...agents, transform } } }; +} diff --git a/cli/src/presentation/commands/translate.ts b/cli/src/presentation/commands/translate.ts index 79830cce0..d0bba810c 100644 --- a/cli/src/presentation/commands/translate.ts +++ b/cli/src/presentation/commands/translate.ts @@ -21,6 +21,7 @@ interface TranslateExecutionParams { target: FrameworkBuildTarget; mode: FrameworkBuildMode; force: boolean; + provider?: string; } /** The build+report core, once `--to`/`--as`/`--out` flags are validated and resolved. */ @@ -33,6 +34,7 @@ async function runTranslateCore(params: TranslateExecutionParams): Promise mode: params.mode, outDir: params.outDir, force: params.force, + ...(params.provider === undefined ? {} : { provider: params.provider }), }); if (useCase === undefined) { params.output.error( @@ -61,6 +63,7 @@ interface TranslateCmdOptions { out: string; as?: string; force?: boolean; + provider?: string; } export function registerTranslateCommand(program: Command): void { @@ -77,6 +80,10 @@ export function registerTranslateCommand(program: Command): void { .requiredOption("--out ", "Output directory (marketplace dist or project root)") .option("--as ", "Output layout", "marketplace") .option("--force", "Overwrite existing files at canonical paths under --out") + .option( + "--provider ", + "Give each agent its role's model at this provider (openai, anthropic, mistral)" + ) .action(async (source: string, cmdOptions: TranslateCmdOptions) => { const { verbose, output, projectRoot } = parseGlobalOptions(program); @@ -106,6 +113,7 @@ export function registerTranslateCommand(program: Command): void { target: cmdOptions.to as FrameworkBuildTarget, mode, force: cmdOptions.force ?? false, + ...(cmdOptions.provider === undefined ? {} : { provider: cmdOptions.provider }), }); }); } diff --git a/cli/src/runtime/wiring/translate.ts b/cli/src/runtime/wiring/translate.ts index 5c7ee0c41..f7d16e327 100644 --- a/cli/src/runtime/wiring/translate.ts +++ b/cli/src/runtime/wiring/translate.ts @@ -9,6 +9,7 @@ import "../../contexts/tools/domain/profiles/kilo/profile.js"; import "../../contexts/tools/domain/profiles/opencode/profile.js"; import "../../contexts/tools/domain/profiles/vscode/profile.js"; import type { ToolBuildContract } from "../../contexts/tools/domain/build-contract.js"; +import { withAgentModels } from "../../contexts/tools/domain/formats/agent-models.js"; import type { FileMerger } from "../../contexts/tools/domain/ports/file-merger.js"; import { buildContractFor } from "../../contexts/tools/domain/registry.js"; import { FlatBuildStrategy } from "../../contexts/translate/application/strategies/flat-build-strategy.js"; @@ -32,6 +33,8 @@ export interface FrameworkBuildContext { readonly mode: string; readonly outDir: string; readonly force: boolean; + /** Gives each agent its role's model at this provider; absent, agents inherit the session's. */ + readonly provider?: string; } type FrameworkBuildFactory = ( @@ -68,12 +71,16 @@ function frameworkBuildFactoryFor( buildContract: () => ToolBuildContract, mode: "marketplace" | "flat" ): FrameworkBuildFactory { + const contractFor = (ctx: FrameworkBuildContext): ToolBuildContract => + ctx.provider === undefined + ? buildContract() + : withAgentModels(buildContract(), ctx.target, ctx.provider); if (mode === "marketplace") { return (deps, ctx) => buildFrameworkUseCase( deps, (d, av) => - new MarketplaceBuildStrategy(d.fs, av, d.assetProvider, buildContract(), ctx.force) + new MarketplaceBuildStrategy(d.fs, av, d.assetProvider, contractFor(ctx), ctx.force) ); } return (deps, ctx) => @@ -84,7 +91,7 @@ function frameworkBuildFactoryFor( d.fs, av, d.assetProvider, - buildContract(), + contractFor(ctx), ctx.force, ctx.outDir, isDirectory, diff --git a/cli/tests/contexts/tools/domain/formats/agent-frontmatter-strip.unit.test.ts b/cli/tests/contexts/tools/domain/formats/agent-frontmatter-strip.unit.test.ts deleted file mode 100644 index 8d9b51fc7..000000000 --- a/cli/tests/contexts/tools/domain/formats/agent-frontmatter-strip.unit.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - COPILOT_AGENT_FRONTMATTER_KEYS, - CURSOR_AGENT_FRONTMATTER_KEYS, - stripCopilotAgentFrontmatter, - stripCursorAgentFrontmatter, - stripPathIdAgentFrontmatter, -} from "../../../../../src/contexts/tools/domain/formats/agent-frontmatter-strip.js"; - -describe("stripCopilotAgentFrontmatter", () => { - it("keeps name, description and model, and reads Claude's effort as reasoningEffort", () => { - expect( - stripCopilotAgentFrontmatter({ - name: "reviewer", - description: "Reviews code", - model: "claude-sonnet", - effort: "high", - }) - ).toEqual({ - name: "reviewer", - description: "Reviews code", - model: "claude-sonnet", - reasoningEffort: "high", - "include-custom-instructions": true, - }); - }); - - it("drops tools and every other key, so the agent keeps Copilot's default of all tools", () => { - const result = stripCopilotAgentFrontmatter({ - name: "reviewer", - tools: ["Read"], - agents: ["helper"], - "argument-hint": "[file]", - color: "red", - }); - expect(Object.keys(result)).toEqual(["name", "include-custom-instructions"]); - }); - - it("preserves an explicit repository-instruction opt-out", () => { - expect(stripCopilotAgentFrontmatter({ "include-custom-instructions": false })).toEqual({ - "include-custom-instructions": false, - }); - }); - - it("returns keys in allowlist order for deterministic serialization", () => { - const result = stripCopilotAgentFrontmatter({ - effort: "low", - model: "sonnet", - name: "reviewer", - description: "desc", - }); - expect(Object.keys(result)).toEqual(COPILOT_AGENT_FRONTMATTER_KEYS); - }); -}); - -describe("stripPathIdAgentFrontmatter", () => { - it("keeps description and model only: the file name is the ID, and no key restricts tools", () => { - expect( - stripPathIdAgentFrontmatter({ - name: "explorer", - description: "d", - model: "openai/gpt", - effort: "high", - tools: "Read", - }) - ).toEqual({ description: "d", model: "openai/gpt" }); - }); -}); - -describe("stripCursorAgentFrontmatter", () => { - it("drops tools, color, argument-hint — keeps only name/description/model", () => { - const input = { - name: "planner", - description: "Plans tasks", - model: "claude-sonnet", - tools: ["Read", "Write"], - color: "#ff0000", - "argument-hint": "[task]", - }; - const result = stripCursorAgentFrontmatter(input); - expect(result).toEqual({ - name: "planner", - description: "Plans tasks", - model: "claude-sonnet", - }); - expect(result).not.toHaveProperty("tools"); - expect(result).not.toHaveProperty("color"); - expect(result).not.toHaveProperty("argument-hint"); - }); - - it("preserves a subset: name only", () => { - const result = stripCursorAgentFrontmatter({ name: "agent" }); - expect(result).toEqual({ name: "agent" }); - }); - - it("omits model when undefined", () => { - const result = stripCursorAgentFrontmatter({ name: "agent", model: undefined }); - expect(result).not.toHaveProperty("model"); - }); - - it("returns keys in CURSOR_AGENT_FRONTMATTER_KEYS order", () => { - const input = { model: "sonnet", description: "desc", name: "agent" }; - const result = stripCursorAgentFrontmatter(input); - expect(Object.keys(result)).toEqual(CURSOR_AGENT_FRONTMATTER_KEYS.filter((k) => k in input)); - }); - - it("CURSOR_AGENT_FRONTMATTER_KEYS is exactly [name, description, model]", () => { - expect(CURSOR_AGENT_FRONTMATTER_KEYS).toEqual(["name", "description", "model"]); - }); -}); diff --git a/cli/tests/contexts/tools/domain/formats/agent-models.unit.test.ts b/cli/tests/contexts/tools/domain/formats/agent-models.unit.test.ts new file mode 100644 index 000000000..e39de535e --- /dev/null +++ b/cli/tests/contexts/tools/domain/formats/agent-models.unit.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import type { ToolBuildContract } from "../../../../../src/contexts/tools/domain/build-contract.js"; +import { withAgentModels } from "../../../../../src/contexts/tools/domain/formats/agent-models.js"; +import { buildClaudeFlatContract } from "../../../../../src/contexts/tools/domain/profiles/claude/build.js"; +import { buildCodexFlatContract } from "../../../../../src/contexts/tools/domain/profiles/codex/build.js"; +import { buildCopilotFlatContract } from "../../../../../src/contexts/tools/domain/profiles/copilot/build.js"; +import { buildKiloFlatContract } from "../../../../../src/contexts/tools/domain/profiles/kilo/build.js"; +import { buildOpencodeFlatContract } from "../../../../../src/contexts/tools/domain/profiles/opencode/build.js"; + +const AGENT = `--- +description: Reviews the work +--- +Review. +`; + +function write(contract: ToolBuildContract, fileName: string, content = AGENT): string { + const agents = contract.artifacts.agents; + if (!agents.supported) throw new Error("no agents"); + return agents.transform?.(content, "aidd-orchestrator", fileName) ?? content; +} + +describe("withAgentModels()", () => { + it("gives a light role the provider's light model and effort, in the host's keys", () => { + expect( + write(withAgentModels(buildCodexFlatContract(), "codex", "openai"), "explorer.md") + ).toBe(`name = "aidd-orchestrator-explorer" +description = "Reviews the work" +model = "gpt-6-luna" +model_reasoning_effort = "medium" +developer_instructions = "Review.\\n" +`); + }); + + it("gives the reviewer the standard model, prefixed by the provider where the host wants it", () => { + expect( + write(withAgentModels(buildOpencodeFlatContract(), "opencode", "mistral"), "reviewer.md") + ).toBe(`--- +description: 'Reviews the work' +model: 'mistral/mistral-medium-latest#high' +mode: 'subagent' +--- +Review. +`); + }); + + it("uses the host's own id for a model it names differently", () => { + expect( + write(withAgentModels(buildCopilotFlatContract(), "copilot", "anthropic"), "validator.md") + ).toBe(`--- +name: 'aidd-orchestrator-validator' +description: 'Reviews the work' +model: 'claude-haiku-5.5' +reasoningEffort: 'medium' +include-custom-instructions: true +--- +Review. +`); + }); + + it("leaves an agent whose role is not listed, or that declares its own model, as written", () => { + const contract = withAgentModels(buildClaudeFlatContract(), "claude", "anthropic"); + const plain = buildClaudeFlatContract(); + const pinned = "---\ndescription: d\nmodel: claude-opus-5-5\n---\nBody.\n"; + expect(write(contract, "planner.md")).toBe(write(plain, "planner.md")); + expect(write(contract, "explorer.md", pinned)).toBe(write(plain, "explorer.md", pinned)); + }); + + it("gives a Kilo agent a provider-prefixed model and the effort as its variant", () => { + expect(write(withAgentModels(buildKiloFlatContract(), "kilo", "anthropic"), "explorer.md")) + .toBe(`--- +description: 'Reviews the work' +model: 'anthropic/claude-haiku-5-5' +variant: 'medium' +mode: 'subagent' +--- +Review. +`); + }); + + it("refuses a provider the host cannot run, and a host with no provider", () => { + expect(() => withAgentModels(buildCodexFlatContract(), "codex", "mistral")).toThrow( + 'Provider "mistral" is not supported for "codex". Use: openai.' + ); + expect(() => withAgentModels(buildCodexFlatContract(), "vscode", "openai")).toThrow( + 'No agent provider is supported for "vscode".' + ); + }); +}); diff --git a/cli/tests/golden/snapshots/help/surface.json b/cli/tests/golden/snapshots/help/surface.json index 449726e32..322934855 100644 --- a/cli/tests/golden/snapshots/help/surface.json +++ b/cli/tests/golden/snapshots/help/surface.json @@ -182,7 +182,7 @@ { "invocation": "aidd translate", "exitCode": 0, - "help": "Usage: aidd translate [options] \n\nConvert an arbitrary source into a target-native plugin tree — records nothing\n(see `sync` for the manifest-driven, tracked version)\n\nArguments:\n source Path to the source framework directory\n\nOptions:\n --to Conversion target (claude, cursor, copilot, codex,\n opencode, kilo)\n --out Output directory (marketplace dist or project root)\n --as Output layout (default: \"marketplace\")\n --force Overwrite existing files at canonical paths under\n --out\n -h, --help display help for command" + "help": "Usage: aidd translate [options] \n\nConvert an arbitrary source into a target-native plugin tree — records nothing\n(see `sync` for the manifest-driven, tracked version)\n\nArguments:\n source Path to the source framework directory\n\nOptions:\n --to Conversion target (claude, cursor, copilot, codex,\n opencode, kilo)\n --out Output directory (marketplace dist or project root)\n --as Output layout (default: \"marketplace\")\n --force Overwrite existing files at canonical paths under\n --out\n --provider Give each agent its role's model at this provider\n (openai, anthropic, mistral)\n -h, --help display help for command" }, { "invocation": "aidd update", diff --git a/cli/tests/presentation/commands/translate-wiring.integration.test.ts b/cli/tests/presentation/commands/translate-wiring.integration.test.ts index 5fb23b017..a419e9f59 100644 --- a/cli/tests/presentation/commands/translate-wiring.integration.test.ts +++ b/cli/tests/presentation/commands/translate-wiring.integration.test.ts @@ -217,6 +217,11 @@ describe("aidd translate — the help surface", () => { ["--out ", "Output directory (marketplace dist or project root)", undefined], ["--as ", "Output layout", "marketplace"], ["--force", "Overwrite existing files at canonical paths under --out", undefined], + [ + "--provider ", + "Give each agent its role's model at this provider (openai, anthropic, mistral)", + undefined, + ], ]); expect( translateCommand() diff --git a/docs/SUBAGENTS.md b/docs/SUBAGENTS.md index 8415496a3..cff817f4c 100644 --- a/docs/SUBAGENTS.md +++ b/docs/SUBAGENTS.md @@ -45,7 +45,7 @@ Only `/aidd-orchestrator:01-sdlc` names an agent, by its role (`validator`), so | [Explorer](../plugins/aidd-orchestrator/agents/explorer.md) | Find files and symbols, trace a flow, answer a codebase question before a change | The answer, with sources and unknowns | No | | [Executor](../plugins/aidd-orchestrator/agents/executor.md) | Write or fix code and docs within a defined scope, then verify them | `COMPLETE`, `INCOMPLETE` or `BLOCKED`, with changes and checks run | Yes | | [Validator](../plugins/aidd-orchestrator/agents/validator.md) | Confirm delivered work meets the request and its acceptance criteria | `Validated: yes` or `no`, with what failed | No; runs checks | -| [Reviewer](../plugins/aidd-orchestrator/agents/reviewer.md) | Find defects, contradictions, duplication and needless complexity in finished work | `no actionable findings`, `changes needed` or `insufficient evidence`, with findings | No; only the review report a workflow asks for | +| [Reviewer](../plugins/aidd-orchestrator/agents/reviewer.md) | Find defects, contradictions, duplication and needless complexity in finished work | `no actionable findings`, `changes needed` or `insufficient evidence`, with findings | No | ## 📦 Install @@ -53,13 +53,28 @@ Install the `aidd-orchestrator` plugin: `/plugin install aidd-orchestrator@aidd- ## 🧠 Models -No model is pinned. Each host applies its own default for subagents, usually the model of the session that called them. A model you declare in an agent reaches every host in its own format. +The plugin ships no model, so an agent runs on the model of the session that called it. A build pins one per role and provider from [`agent-models.json`](../cli/src/contexts/tools/domain/formats/agent-models.json): the explorer, executor and validator get the provider's light model, the reviewer its standard one, each with an effort written the host's way. + +```bash +aidd translate --to opencode --as flat --out . --provider openai +``` + +| Host | Providers | Written as | +| --- | --- | --- | +| Claude Code | anthropic | `model`, `effort` | +| Codex | openai | `model`, `model_reasoning_effort` | +| Copilot | openai, anthropic | `model`, `reasoningEffort` | +| OpenCode | openai, anthropic, mistral | `model: /#` | +| Cursor | anthropic | `model: [effort=]` | +| Kilo | openai, anthropic, mistral | `model: /`, `variant` | + +`make reload` reads the provider per host from [`scripts/reload-agents.json`](../scripts/reload-agents.json). A model an agent declares itself is kept. ## 🚧 Limits - **Tools:** every agent inherits the calling session's tools and permissions; what it writes is an instruction, not a restriction. - **Codex:** a Codex plugin cannot carry agents, so only the flat distribution installs them, in `.codex/agents/`. Codex loads them only in a trusted project. -- **Models:** OpenCode V1 reads the same file but not the `#` variant. +- **Models:** OpenCode V1 reads the same file but not the `#` variant. A plugin install (`/plugin install`, `aidd setup`) pins no model; only a build does. - **Updates:** reinstalling replaces the agents still shipped; one removed from the plugin stays where a copy put it until you delete it. Sources: [Claude Code sub-agents](https://code.claude.com/docs/en/sub-agents), [Copilot custom agents](https://docs.github.com/en/copilot/reference/custom-agents-configuration), [OpenCode agents](https://opencode.ai/v2/docs/agents), [Codex subagents](https://developers.openai.com/codex/subagents), [Cursor subagents](https://cursor.com/docs/subagents). diff --git a/scripts/__tests__/dev-sync.test.js b/scripts/__tests__/dev-sync.test.js index 7d1bfb714..2036589fc 100644 --- a/scripts/__tests__/dev-sync.test.js +++ b/scripts/__tests__/dev-sync.test.js @@ -195,6 +195,7 @@ function runSync(fakeBin, home, args = ["all"], overrides = {}) { COPILOT_INSTRUCTIONS: join(home, ".copilot/copilot-instructions.md"), CURSOR_INSTRUCTIONS: join(home, ".cursor/rules/aidd-agents.mdc"), CURSOR_PLUGINS: join(home, ".cursor/plugins/local"), + RELOAD_AGENTS: join(home, "no-reload-agents.json"), OPENCODE_DISABLE_CLAUDE_CODE: "", OPENCODE_DISABLE_CLAUDE_CODE_PROMPT: "", ...overrides, @@ -779,6 +780,7 @@ for (const customPluginRoot of [false, true]) { const fakeBin = join(root, "bin"), home = join(root, "home"), config = join(home, "native-claude"); const plugins = customPluginRoot ? join(home, "native-plugins") : join(config, "plugins"); mkdirSync(fakeBin); + executable(join(fakeBin, "npx"), "exit 0"); executable(join(fakeBin, "claude"), String.raw`if [ "$1 $2" = 'plugin install' ]; then cache="$CLAUDE_CONFIG_DIR/plugins" [ -z "$CLAUDE_CODE_PLUGIN_CACHE_DIR" ] || cache="$CLAUDE_CODE_PLUGIN_CACHE_DIR" @@ -842,3 +844,25 @@ printf '%s\n' 'built executor' > "$out/plugins/aidd-dev/codex-agents/executor.to for (const file of existing) assert.equal(readFileSync(join(agents, file), "utf8"), `Personal ${file}\n`); } finally { rmSync(root, { recursive: true, force: true }); } }); + +test("reload builds each host with the agent provider its file names, and Claude from that build", () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-provider-")); + try { + const fakeBin = join(root, "bin"), home = join(root, "home"); + mkdirSync(fakeBin); + const providers = join(home, "reload-agents.json"); + mkdirSync(home, { recursive: true }); + writeFileSync(providers, JSON.stringify({ claude: "anthropic", codex: "openai" })); + executable(join(fakeBin, "npx"), String.raw`printf '%s +' "$*" >> "$HOME/translate-calls"`); + executable(join(fakeBin, "codex"), "exit 0"); + executable(join(fakeBin, "claude"), String.raw`printf '%s +' "$*" >> "$HOME/claude-calls"`); + const result = runSync(fakeBin, home, [], { RELOAD_AGENTS: providers }); + assert.equal(result.status, 0, result.stderr); + const calls = readFileSync(join(home, "translate-calls"), "utf8"); + assert.match(calls, /--to codex .*--provider openai/); + assert.match(calls, /--to claude .*--provider anthropic/); + assert.match(readFileSync(join(home, "claude-calls"), "utf8"), new RegExp(`marketplace add ${join(home, "build", "claude").replaceAll("\\", "\\\\")} --scope user`)); + } finally { rmSync(root, { recursive: true, force: true }); } +}); diff --git a/scripts/dev-sync.sh b/scripts/dev-sync.sh index 32aad997a..7e32c1449 100755 --- a/scripts/dev-sync.sh +++ b/scripts/dev-sync.sh @@ -1,8 +1,9 @@ #!/usr/bin/env bash # (Re)install every plugin into Claude, Codex, OpenCode, Copilot and Cursor from THIS checkout. -# Claude reads the raw repo, already native; the others install from a tree the aidd CLI -# builds, so what runs locally matches what ships. Codex and OpenCode load no -# agent from a plugin, so their built agents are copied to the user's agents directory. +# Every host installs from a tree the aidd CLI builds, so what runs locally matches what ships. +# Codex and OpenCode load no agent from a plugin, so their built agents are copied to the +# user's agents directory. A host listed in scripts/reload-agents.json (or the file +# RELOAD_AGENTS names) gives each agent its role's model at that provider. # # NOT live: the install copies built files, so an edit needs a re-run. Idempotent; a tool # whose CLI is absent is skipped, and the first run needs network. A managed OpenCode host @@ -34,6 +35,7 @@ FW="${FW:-$(dirname "$SCRIPT_DIR")}" MKT="${MKT:-aidd-framework}" AIDD_CLI_VERSION="${AIDD_CLI_VERSION:-latest}" # published fallback for sources without a built CLI BUILD="${BUILD:-$HOME/.cache/aidd-framework-dev}" # per-tool native trees the marketplaces point at +RELOAD_AGENTS="${RELOAD_AGENTS:-$SCRIPT_DIR/reload-agents.json}" CODEX_CACHE="${CODEX_CACHE:-${CODEX_HOME:-$HOME/.codex}/plugins/cache}" CLAUDE_CACHE="${CLAUDE_CACHE:-${CLAUDE_CODE_PLUGIN_CACHE_DIR:-${CLAUDE_CONFIG_DIR:-$HOME/.claude}/plugins}/cache}" CODEX_AGENTS="${CODEX_AGENTS:-${CODEX_HOME:-$HOME/.codex}/agents}" @@ -58,17 +60,24 @@ elif command -v agent >/dev/null 2>&1 && agent --help 2>/dev/null | grep 'Start HAVE_CURSOR=1 fi +agent_provider() { + [ -f "$RELOAD_AGENTS" ] || return 0 + node -e 'const p = JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8"))[process.argv[2]]; + if (p !== undefined && typeof p !== "string") throw new Error(`${process.argv[2]} needs a provider name`); + process.stdout.write(p ?? "");' "$RELOAD_AGENTS" "$1" +} + build_tool() { - local tool="$1" + local tool="$1" provider rm -rf "$BUILD/$tool"; mkdir -p "$BUILD/$tool" - local mode=marketplace - [ "$tool" != opencode ] || mode=flat + local -a args=(translate "$FW" --to "$tool" --out "$BUILD/$tool" --force) + [ "$tool" != opencode ] || args+=(--as flat) + provider="$(agent_provider "$tool")" || return 1 + [ -z "$provider" ] || args+=(--provider "$provider") if [ -f "$FW/cli/dist/cli.js" ]; then - node "$FW/cli/dist/cli.js" translate "$FW" \ - --to "$tool" --out "$BUILD/$tool" --as "$mode" --force >/dev/null 2>&1 + node "$FW/cli/dist/cli.js" "${args[@]}" >/dev/null 2>&1 else - npx --yes "@ai-driven-dev/cli@${AIDD_CLI_VERSION}" translate "$FW" \ - --to "$tool" --out "$BUILD/$tool" --as "$mode" --force >/dev/null 2>&1 + npx --yes "@ai-driven-dev/cli@${AIDD_CLI_VERSION}" "${args[@]}" >/dev/null 2>&1 fi } @@ -138,11 +147,11 @@ register_marketplace() { codex) codex plugin marketplace remove "$MKT" >/dev/null 2>&1 || true codex plugin marketplace add "$BUILD/codex" >/dev/null 2>&1 ;; - # Claude installs the native repository. Scope every op to user: bare `marketplace remove` strips - # the declaration from EVERY scope, wiping the repo's own project-scoped config. + # Scope every Claude op to user: bare `marketplace remove` strips the declaration from + # EVERY scope, wiping the repo's own project-scoped config. claude) claude plugin marketplace remove "$MKT" --scope user >/dev/null 2>&1 || true - claude plugin marketplace add "$FW" --scope user >/dev/null 2>&1 ;; + claude plugin marketplace add "$BUILD/claude" --scope user >/dev/null 2>&1 ;; copilot) local registered_source registered_source="$(copilot plugin marketplace list --json | node -e ' @@ -216,8 +225,6 @@ fi targets=() for d in "$FW"/plugins/*/; do targets+=("$(basename "$d")"); done -# Codex needs a native plugin build; register against it. Claude installs from the raw -# repo (already native), so it only needs the marketplace registered - no build. if [ "$HAVE_CODEX" = 1 ]; then printf '%-22s' "build codex" build_tool codex || { echo "FAIL"; echo "codex build failed; rerun the translate command in scripts/dev-sync.sh without output redirection for details." >&2; exit 1; } @@ -225,7 +232,10 @@ if [ "$HAVE_CODEX" = 1 ]; then echo "ok" fi if [ "$HAVE_CLAUDE" = 1 ]; then - register_marketplace claude + printf '%-22s' "build claude" + build_tool claude || { echo "FAIL"; echo "claude build failed; rerun the translate command in scripts/dev-sync.sh without output redirection for details." >&2; exit 1; } + register_marketplace claude || { echo "FAIL"; echo "Claude marketplace registration failed for $BUILD/claude." >&2; exit 1; } + echo "ok" fi if [ "$HAVE_OPENCODE" = 1 ] && [ "$HAVE_MANAGED_OPENCODE" = 0 ]; then printf '%-22s' "build opencode" diff --git a/scripts/reload-agents.json b/scripts/reload-agents.json new file mode 100644 index 000000000..989afde95 --- /dev/null +++ b/scripts/reload-agents.json @@ -0,0 +1,5 @@ +{ + "claude": "anthropic", + "codex": "openai", + "opencode": "mistral" +} From 2ecb04b52dc17b930da427bd2dabe652367f488f Mon Sep 17 00:00:00 2001 From: alexsoyes Date: Wed, 28 Oct 2026 04:16:31 +0000 Subject: [PATCH 2/2] test(cli): pin the provider flag's forwarding, and compare reload paths across platforms --- .../domain/formats/agent-models.unit.test.ts | 5 +++-- .../translate-wiring.integration.test.ts | 21 +++++++++++++++++++ scripts/__tests__/dev-sync.test.js | 4 +++- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/cli/tests/contexts/tools/domain/formats/agent-models.unit.test.ts b/cli/tests/contexts/tools/domain/formats/agent-models.unit.test.ts index e39de535e..7ca0ac803 100644 --- a/cli/tests/contexts/tools/domain/formats/agent-models.unit.test.ts +++ b/cli/tests/contexts/tools/domain/formats/agent-models.unit.test.ts @@ -66,8 +66,9 @@ Review. }); it("gives a Kilo agent a provider-prefixed model and the effort as its variant", () => { - expect(write(withAgentModels(buildKiloFlatContract(), "kilo", "anthropic"), "explorer.md")) - .toBe(`--- + expect( + write(withAgentModels(buildKiloFlatContract(), "kilo", "anthropic"), "explorer.md") + ).toBe(`--- description: 'Reviews the work' model: 'anthropic/claude-haiku-5-5' variant: 'medium' diff --git a/cli/tests/presentation/commands/translate-wiring.integration.test.ts b/cli/tests/presentation/commands/translate-wiring.integration.test.ts index a419e9f59..bdbd875fe 100644 --- a/cli/tests/presentation/commands/translate-wiring.integration.test.ts +++ b/cli/tests/presentation/commands/translate-wiring.integration.test.ts @@ -95,6 +95,27 @@ describe("aidd translate — what it hands the build", () => { expect(build).toHaveBeenCalledWith(expect.objectContaining({ mode: "flat" })); }); + it("carries an agent provider through to the factory alone", async () => { + await run( + "translate", + "framework", + "--to", + "claude", + "--out", + "dist-plugins", + "--provider", + "anthropic" + ); + + expect(vi.mocked(createFrameworkBuildUseCase)).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ provider: "anthropic" }) + ); + expect(build).toHaveBeenCalledWith( + expect.not.objectContaining({ provider: expect.anything() }) + ); + }); + it("carries an overwrite through to the factory alone", async () => { await run("translate", "framework", "--to", "claude", "--out", "dist-plugins", "--force"); diff --git a/scripts/__tests__/dev-sync.test.js b/scripts/__tests__/dev-sync.test.js index 2036589fc..d254b56d7 100644 --- a/scripts/__tests__/dev-sync.test.js +++ b/scripts/__tests__/dev-sync.test.js @@ -863,6 +863,8 @@ test("reload builds each host with the agent provider its file names, and Claude const calls = readFileSync(join(home, "translate-calls"), "utf8"); assert.match(calls, /--to codex .*--provider openai/); assert.match(calls, /--to claude .*--provider anthropic/); - assert.match(readFileSync(join(home, "claude-calls"), "utf8"), new RegExp(`marketplace add ${join(home, "build", "claude").replaceAll("\\", "\\\\")} --scope user`)); + const added = readFileSync(join(home, "claude-calls"), "utf8").match(/marketplace add (.+) --scope user/); + assert.ok(added, "Claude marketplace was never added"); + assert.equal(realpathSync(added[1]), realpathSync(join(home, "build/claude"))); } finally { rmSync(root, { recursive: true, force: true }); } });