From 44d9d7d5fa4fa6f47649191b8897e08fcd030af5 Mon Sep 17 00:00:00 2001 From: Uday Chandra Date: Thu, 7 May 2026 01:52:15 +0000 Subject: [PATCH 1/2] Support spec-level agent routing --- README.md | 11 ++++- .../main/java/ai/singlr/sail/config/Spec.java | 29 ++++++++++- .../ai/singlr/sail/config/SpecDirectory.java | 1 + .../sail/gen/AgentContextGenerator.java | 7 ++- .../singlr/sail/gen/SpecSkillGenerator.java | 10 ++++ .../java/ai/singlr/sail/config/SpecTest.java | 15 ++++++ .../java/ai/singlr/sail/api/ApiModels.java | 3 +- .../ai/singlr/sail/api/SailApiOperations.java | 18 ++++--- .../singlr/sail/commands/DispatchCommand.java | 5 +- .../sail/commands/SpecCreateCommand.java | 8 ++++ .../ai/singlr/sail/engine/SpecWorkspace.java | 1 + .../ai/singlr/sail/api/ApiRouterTest.java | 3 +- .../sail/api/SailApiOperationsTest.java | 48 +++++++++++++++++++ .../sail/commands/DispatchCommandTest.java | 11 +++++ 14 files changed, 158 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 24e1f47..b263b29 100644 --- a/README.md +++ b/README.md @@ -99,9 +99,15 @@ status: pending assignee: bob depends_on: - oauth-flow +repo: webapp +agent: codex branch: feat/payment-integration ``` +`repo`/`repos` routes work to the right repository in multi-repo projects. `agent` routes a spec +to a specific installed agent (`claude-code`, `codex`, or `gemini`); if omitted, dispatch uses +`agent.type` from `sail.yaml`. + ### Lifecycle ``` @@ -253,6 +259,7 @@ agent: type: claude-code install: - claude-code + - codex - gemini security_audit: enabled: true @@ -261,7 +268,9 @@ agent: enabled: true ``` -Claude codes. Gemini reviews. Or the same agent does both with fresh context. The hooks fire at spec completion, not session stop — so reviews always see complete, coherent work. +Claude, Codex, or Gemini can execute a spec. Set `agent: codex` in `spec.yaml` to override the +project default for that unit of work. The hooks fire at spec completion, not session stop — so +reviews always see complete, coherent work. ## Example `sail.yaml` diff --git a/sail-core/src/main/java/ai/singlr/sail/config/Spec.java b/sail-core/src/main/java/ai/singlr/sail/config/Spec.java index df859fe..b92bf05 100644 --- a/sail-core/src/main/java/ai/singlr/sail/config/Spec.java +++ b/sail-core/src/main/java/ai/singlr/sail/config/Spec.java @@ -5,6 +5,7 @@ package ai.singlr.sail.config; +import ai.singlr.sail.engine.AgentCli; import ai.singlr.sail.engine.NameValidator; import java.util.LinkedHashMap; import java.util.List; @@ -22,6 +23,7 @@ * @param assignee engineer responsible (nullable, matches git identity) * @param dependsOn IDs of specs that must be done first * @param repos repository paths this spec should branch and work in + * @param agent agent CLI this spec should run with (nullable) * @param branch git branch for this spec's work (nullable) */ public record Spec( @@ -31,6 +33,7 @@ public record Spec( String assignee, List dependsOn, List repos, + String agent, String branch) { public Spec( @@ -40,7 +43,18 @@ public Spec( String assignee, List dependsOn, String branch) { - this(id, title, status, assignee, dependsOn, List.of(), branch); + this(id, title, status, assignee, dependsOn, List.of(), null, branch); + } + + public Spec( + String id, + String title, + String status, + String assignee, + List dependsOn, + List repos, + String branch) { + this(id, title, status, assignee, dependsOn, repos, null, branch); } @SuppressWarnings("unchecked") @@ -55,6 +69,7 @@ public static Spec fromMap(Map map) { var assignee = (String) map.get("assignee"); var dependsOn = (List) map.get("depends_on"); var repos = reposFromMap(map); + var agent = validatedAgent((String) map.get("agent")); var branch = (String) map.get("branch"); return new Spec( id, @@ -63,6 +78,7 @@ public static Spec fromMap(Map map) { assignee, dependsOn != null ? List.copyOf(dependsOn) : List.of(), repos, + agent, branch); } @@ -84,6 +100,9 @@ public Map toMap() { } else if (!repos.isEmpty()) { map.put("repos", repos); } + if (agent != null) { + map.put("agent", agent); + } if (branch != null) { map.put("branch", branch); } @@ -109,4 +128,12 @@ private static List validatedRepos(List repos) { repos.forEach(repo -> NameValidator.requireSafePath(repo, "spec.repo")); return List.copyOf(repos); } + + private static String validatedAgent(String agent) { + if (agent == null || agent.isBlank()) { + return null; + } + AgentCli.fromYamlName(agent); + return agent; + } } diff --git a/sail-core/src/main/java/ai/singlr/sail/config/SpecDirectory.java b/sail-core/src/main/java/ai/singlr/sail/config/SpecDirectory.java index 1534212..b03762c 100644 --- a/sail-core/src/main/java/ai/singlr/sail/config/SpecDirectory.java +++ b/sail-core/src/main/java/ai/singlr/sail/config/SpecDirectory.java @@ -107,6 +107,7 @@ public static List updateStatus(List specs, String specId, String ne spec.assignee(), spec.dependsOn(), spec.repos(), + spec.agent(), spec.branch()) : spec) .toList(); diff --git a/sail-core/src/main/java/ai/singlr/sail/gen/AgentContextGenerator.java b/sail-core/src/main/java/ai/singlr/sail/gen/AgentContextGenerator.java index bfa017b..b9320d9 100644 --- a/sail-core/src/main/java/ai/singlr/sail/gen/AgentContextGenerator.java +++ b/sail-core/src/main/java/ai/singlr/sail/gen/AgentContextGenerator.java @@ -455,7 +455,7 @@ private static void appendSpecSection(StringBuilder sb, SailYaml config) { ``` %s/ ├── oauth-flow/ - │ ├── spec.yaml # Metadata: id, title, status, assignee, depends_on, repo/repos, branch + │ ├── spec.yaml # Metadata: id, title, status, assignee, depends_on, repo/repos, agent, branch │ ├── spec.md # Detailed specification │ └── plan.md # Optional implementation plan └── search-api/ @@ -474,6 +474,7 @@ private static void appendSpecSection(StringBuilder sb, SailYaml config) { assignee: claude-code depends_on: [] repo: app + agent: codex branch: feat/oauth-flow ``` @@ -481,6 +482,10 @@ private static void appendSpecSection(StringBuilder sb, SailYaml config) { work. The values must match `repos[].path` in `sail.yaml`. If a multi-repository project omits repo targeting, `sail spec dispatch` will not auto-create a branch. + Use `agent: codex`, `agent: claude-code`, or `agent: gemini` when a spec should run on + a specific installed agent. If omitted, `sail spec dispatch` uses `agent.type` from + `sail.yaml`. + ### Status Lifecycle `pending` → `in_progress` → `review` → `done` Spec status is managed by `sail`, not by you. Do not modify spec status directly during autonomous execution. diff --git a/sail-core/src/main/java/ai/singlr/sail/gen/SpecSkillGenerator.java b/sail-core/src/main/java/ai/singlr/sail/gen/SpecSkillGenerator.java index d9a502b..399120b 100644 --- a/sail-core/src/main/java/ai/singlr/sail/gen/SpecSkillGenerator.java +++ b/sail-core/src/main/java/ai/singlr/sail/gen/SpecSkillGenerator.java @@ -243,6 +243,7 @@ private static String createInstructions(String specsDir) { status: pending depends_on: [] repo: + agent: ``` 4. Write `%1$s//spec.md` using the spec template 5. Confirm: "Created spec `` — fill in the details in `%1$s//spec.md`" @@ -256,6 +257,10 @@ private static String createInstructions(String specsDir) { Ask which repository the spec targets when the project has multiple repos. Use \ `repo: ` for one repo and `repos: [repo-a, repo-b]` for cross-repo work. \ Values must match `repos[].path` in `sail.yaml`. + + Ask which agent should execute the spec when the project has multiple installed agents. \ + Use `agent: codex`, `agent: claude-code`, or `agent: gemini`. If omitted, Sail uses \ + `agent.type` from `sail.yaml`. """ .formatted(specsDir); } @@ -308,6 +313,7 @@ private static String coreReference(String specsDir) { assignee: claude-code depends_on: [] repo: app + agent: codex branch: feat/oauth-flow ``` @@ -331,12 +337,16 @@ private static String coreReference(String specsDir) { - **depends_on** (optional): list of spec ids that must be done first - **repo** (optional): single target repository path from `sail.yaml` `repos[].path` - **repos** (optional): list of target repository paths for cross-repo work + - **agent** (optional): agent CLI for this spec (`claude-code`, `codex`, or `gemini`) - **branch** (optional): git branch name for this spec's work In multi-repo projects, always include `repo` or `repos` before dispatch so Sail can \ create the branch in the right repository. If omitted, dispatch only auto-branches when \ the project has exactly one configured repo. + In multi-agent projects, include `agent` when a spec should run on a non-default agent. \ + If omitted, dispatch uses `agent.type` from `sail.yaml`. + ### Directory Structure ``` %s/ diff --git a/sail-core/src/test/java/ai/singlr/sail/config/SpecTest.java b/sail-core/src/test/java/ai/singlr/sail/config/SpecTest.java index b8a6d4b..a47c9f9 100644 --- a/sail-core/src/test/java/ai/singlr/sail/config/SpecTest.java +++ b/sail-core/src/test/java/ai/singlr/sail/config/SpecTest.java @@ -105,6 +105,20 @@ void toMapContainsAllFields() { assertEquals("feat/auth", map.get("branch")); } + @Test + void parsesAgent() { + var spec = Spec.fromMap(Map.of("id", "auth", "agent", "codex")); + + assertEquals("codex", spec.agent()); + } + + @Test + void rejectsUnknownAgent() { + assertThrows( + IllegalArgumentException.class, + () -> Spec.fromMap(Map.of("id", "auth", "agent", "unknown-agent"))); + } + @Test void toMapWritesSingleRepoAsRepo() { var spec = @@ -164,6 +178,7 @@ void roundTrips() { assertEquals(spec.assignee(), parsed.assignee()); assertEquals(spec.dependsOn(), parsed.dependsOn()); assertEquals(spec.repos(), parsed.repos()); + assertEquals(spec.agent(), parsed.agent()); assertEquals(spec.branch(), parsed.branch()); } diff --git a/sail-infra/src/main/java/ai/singlr/sail/api/ApiModels.java b/sail-infra/src/main/java/ai/singlr/sail/api/ApiModels.java index 85032fa..ff736d2 100644 --- a/sail-infra/src/main/java/ai/singlr/sail/api/ApiModels.java +++ b/sail-infra/src/main/java/ai/singlr/sail/api/ApiModels.java @@ -102,6 +102,7 @@ record SpecView( String assignee, List dependsOn, List repos, + String agent, String branch, boolean ready, boolean blocked, @@ -114,7 +115,7 @@ record SpecView( } record DispatchedSpecView( - String id, String title, String status, List repos, String branch) { + String id, String title, String status, List repos, String agent, String branch) { public DispatchedSpecView { repos = repos == null ? List.of() : List.copyOf(repos); } diff --git a/sail-infra/src/main/java/ai/singlr/sail/api/SailApiOperations.java b/sail-infra/src/main/java/ai/singlr/sail/api/SailApiOperations.java index ab6d86c..8194168 100644 --- a/sail-infra/src/main/java/ai/singlr/sail/api/SailApiOperations.java +++ b/sail-infra/src/main/java/ai/singlr/sail/api/SailApiOperations.java @@ -205,12 +205,13 @@ private DispatchResponse dispatchValue(String project, DispatchRequest request) updateStatus(workspace, nextSpec.id(), "in_progress"); var specBody = Objects.requireNonNullElse(readSpecBody(workspace, nextSpec.id()), ""); var task = buildTaskPrompt(taskSpec, specBody.isBlank() ? nextSpec.title() : specBody); + var agentType = taskSpec.agent() != null ? taskSpec.agent() : loaded.config().agent().type(); var branch = branchName(loaded.config(), nextSpec); var snapshot = createSnapshotIfNeeded(project, loaded.config()); var branchCreated = createBranchIfNeeded(project, loaded.config(), targetRepos, branch); if (!request.dryRun()) { - launchAgent(project, loaded.config(), task, branch, request.mode()); + launchAgent(project, loaded.config(), task, branch, request.mode(), agentType); } var status = request.dryRun() ? null : querySession(agentSession, project); @@ -219,7 +220,7 @@ private DispatchResponse dispatchValue(String project, DispatchRequest request) true, null, dispatchedSpecView(taskSpec, branch), - agentStatusView(loaded.config(), request.mode(), status), + agentStatusView(agentType, request.mode(), status), snapshot, branchCreated); } @@ -404,6 +405,7 @@ private static SpecView specView(List specs, Spec spec) { spec.assignee(), spec.dependsOn(), spec.repos(), + spec.agent(), spec.branch(), SpecDirectory.isReady(specs, spec), SpecDirectory.isBlocked(specs, spec), @@ -445,6 +447,7 @@ private static DispatchedSpecView dispatchedSpecView(Spec spec, String branch) { spec.title(), "in_progress", spec.repos(), + spec.agent(), branch != null && !branch.isBlank() ? branch : null); } @@ -456,6 +459,7 @@ private static Spec withTargetRepos(Spec spec, List targetRepos) spec.assignee(), spec.dependsOn(), targetRepos.stream().map(SailYaml.Repo::path).toList(), + spec.agent(), spec.branch()); } @@ -468,12 +472,14 @@ private static String buildTaskPrompt(Spec spec, String description) { + ": " + String.join(", ", spec.repos()) + "\n"; + var targetAgent = spec.agent() == null ? "" : "\nTarget agent: " + spec.agent() + "\n"; return "Your current spec: \"" + spec.title() + "\" (id: " + spec.id() + ")." + targetRepos + + targetAgent + "\n" + description; } @@ -531,13 +537,13 @@ private boolean createBranchIfNeeded( } private void launchAgent( - String project, SailYaml config, String task, String branch, String mode) { + String project, SailYaml config, String task, String branch, String mode, String agentType) { try { var session = new AgentSession(shell); session.ensureDirectory(project); session.writeTaskFile(project, task); session.writeSession(project, task, Objects.requireNonNullElse(branch, "")); - var agentCli = AgentCli.fromYamlName(config.agent().type()); + var agentCli = AgentCli.fromYamlName(agentType); var workDir = "/home/" + config.sshUser() + "/workspace"; var command = mode.equals("background") @@ -608,9 +614,9 @@ private static AgentConfigView agentConfigView(SailYaml config) { } private static AgentStatusView agentStatusView( - SailYaml config, String mode, AgentSession.SessionInfo info) { + String agentType, String mode, AgentSession.SessionInfo info) { return new AgentStatusView( - config.agent() != null ? config.agent().type() : "", + agentType, mode, info != null && info.running(), info != null ? info.pid() : null, diff --git a/sail-infra/src/main/java/ai/singlr/sail/commands/DispatchCommand.java b/sail-infra/src/main/java/ai/singlr/sail/commands/DispatchCommand.java index 09545b9..85c8774 100644 --- a/sail-infra/src/main/java/ai/singlr/sail/commands/DispatchCommand.java +++ b/sail-infra/src/main/java/ai/singlr/sail/commands/DispatchCommand.java @@ -172,7 +172,7 @@ private void execute() throws Exception { System.out.println(); } - var agentType = config.agent().type(); + var agentType = taskSpec.agent() != null ? taskSpec.agent() : config.agent().type(); var agentCli = AgentCli.fromYamlName(agentType); var workDir = "/home/" + sshUser + "/workspace"; var fullPermissions = true; @@ -299,12 +299,14 @@ static String buildTaskPrompt(Spec spec, String description, String specsDir) { + ": " + String.join(", ", spec.repos()) + "\n"; + var targetAgent = spec.agent() == null ? "" : "\nTarget agent: " + spec.agent() + "\n"; return "Your current spec: \"" + spec.title() + "\" (id: " + spec.id() + ")." + targetRepos + + targetAgent + "\n" + description; } @@ -317,6 +319,7 @@ private static Spec withTargetRepos(Spec spec, List targetRepos) spec.assignee(), spec.dependsOn(), targetRepos.stream().map(SailYaml.Repo::path).toList(), + spec.agent(), spec.branch()); } diff --git a/sail-infra/src/main/java/ai/singlr/sail/commands/SpecCreateCommand.java b/sail-infra/src/main/java/ai/singlr/sail/commands/SpecCreateCommand.java index 53159aa..365cff1 100644 --- a/sail-infra/src/main/java/ai/singlr/sail/commands/SpecCreateCommand.java +++ b/sail-infra/src/main/java/ai/singlr/sail/commands/SpecCreateCommand.java @@ -10,6 +10,7 @@ import ai.singlr.sail.config.SpecDirectory; import ai.singlr.sail.config.SpecScaffold; import ai.singlr.sail.config.YamlUtil; +import ai.singlr.sail.engine.AgentCli; import ai.singlr.sail.engine.Banner; import ai.singlr.sail.engine.ContainerManager; import ai.singlr.sail.engine.ContainerState; @@ -53,6 +54,9 @@ public final class SpecCreateCommand implements Runnable { @Option(names = "--repo", split = ",", description = "Repository path(s) this spec targets.") private List repos; + @Option(names = "--agent", description = "Agent CLI for this spec (claude-code, codex, gemini).") + private String agent; + @Option(names = "--depends-on", split = ",", description = "Comma-separated dependency ids.") private List dependsOn; @@ -85,6 +89,9 @@ private void execute() throws Exception { resolvedDependsOn.forEach(NameValidator::requireValidSpecId); var resolvedRepos = repos != null ? List.copyOf(repos) : List.of(); resolvedRepos.forEach(repo -> NameValidator.requireSafePath(repo, "spec.repo")); + if (agent != null) { + AgentCli.fromYamlName(agent); + } var shell = new ShellExecutor(false); var mgr = new ContainerManager(shell); @@ -123,6 +130,7 @@ private void execute() throws Exception { assignee, resolvedDependsOn, resolvedRepos, + agent, branch); workspace.createSpec(spec, SpecScaffold.markdownTemplate(title)); diff --git a/sail-infra/src/main/java/ai/singlr/sail/engine/SpecWorkspace.java b/sail-infra/src/main/java/ai/singlr/sail/engine/SpecWorkspace.java index 6a440f4..05a48ed 100644 --- a/sail-infra/src/main/java/ai/singlr/sail/engine/SpecWorkspace.java +++ b/sail-infra/src/main/java/ai/singlr/sail/engine/SpecWorkspace.java @@ -85,6 +85,7 @@ public Spec updateStatus(String specId, String newStatus) spec.assignee(), spec.dependsOn(), spec.repos(), + spec.agent(), spec.branch()); writeMetadata(updated); return updated; diff --git a/sail-infra/src/test/java/ai/singlr/sail/api/ApiRouterTest.java b/sail-infra/src/test/java/ai/singlr/sail/api/ApiRouterTest.java index 2718763..4c3f1e6 100644 --- a/sail-infra/src/test/java/ai/singlr/sail/api/ApiRouterTest.java +++ b/sail-infra/src/test/java/ai/singlr/sail/api/ApiRouterTest.java @@ -468,6 +468,7 @@ public Result spec(String project, String specId) { java.util.List.of(), java.util.List.of(), null, + null, true, false, java.util.List.of()), @@ -510,7 +511,7 @@ public Result dispatch(String project, DispatchRequest request true, null, new DispatchedSpecView( - request.specId(), "Spec", "in_progress", request.repos(), null), + request.specId(), "Spec", "in_progress", request.repos(), null, null), null, "", false)); diff --git a/sail-infra/src/test/java/ai/singlr/sail/api/SailApiOperationsTest.java b/sail-infra/src/test/java/ai/singlr/sail/api/SailApiOperationsTest.java index 4ae0a6b..ad8123d 100644 --- a/sail-infra/src/test/java/ai/singlr/sail/api/SailApiOperationsTest.java +++ b/sail-infra/src/test/java/ai/singlr/sail/api/SailApiOperationsTest.java @@ -13,6 +13,7 @@ import java.nio.file.Path; import java.time.Duration; import java.time.Instant; +import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -85,6 +86,14 @@ class SailApiOperationsTest { branch: feat/custom """; + private static final String AUTH_CODEX_SPEC_YAML = + """ + id: auth + title: Add auth + status: pending + agent: codex + """; + private static final String DONE_SPEC_YAML = """ id: done @@ -564,6 +573,32 @@ void dispatchLaunchesBackgroundAgent() throws Exception { assertTrue(get(result, "agent").toString().contains("mode=background")); } + @Test + void dispatchUsesSpecAgentWhenPresent() throws Exception { + var shell = + shell() + .on("incus list ^acme$", RUNNING_JSON) + .on("cat /home/dev/.sail/agent.pid", new ShellExec.Result(1, "", "missing")) + .withCodexSpec() + .on("cat /home/dev/workspace/specs/auth/spec.md", "Do auth") + .on("mkdir -p /home/dev/workspace/specs", "") + .on("printf '%s'", "") + .on("mkdir -p /home/dev/.sail", "") + .on("codex exec --full-auto", ""); + var operations = operations(baseYaml(), shell); + + var result = operations.dispatch("acme", request("auth")); + + assertEquals(true, get(result, "dispatched")); + assertTrue(get(result, "spec").toString().contains("agent=codex")); + assertTrue(get(result, "agent").toString().contains("type=codex")); + assertTrue( + shell.invocations().stream() + .anyMatch(command -> command.contains("codex exec --full-auto"))); + assertFalse( + shell.invocations().stream().anyMatch(command -> command.contains("claude --print"))); + } + @Test void dispatchLaunchesForegroundAgentAndReturnsSessionDetails() throws Exception { var operations = @@ -1103,6 +1138,7 @@ private static FakeShell shell() { private static final class FakeShell implements ShellExec { private final Map scripts = new LinkedHashMap<>(); private final Map failures = new LinkedHashMap<>(); + private final List invocations = new ArrayList<>(); FakeShell on(String pattern, String stdout) { return on(pattern, new Result(0, stdout, "")); @@ -1134,6 +1170,13 @@ FakeShell withAuthBranchSpec() { .on("cat /home/dev/workspace/specs/auth/spec.yaml", AUTH_BRANCH_SPEC_YAML); } + FakeShell withCodexSpec() { + return on( + "find /home/dev/workspace/specs -mindepth 2 -maxdepth 2 -name spec.yaml -print", + "/home/dev/workspace/specs/auth/spec.yaml\n") + .on("cat /home/dev/workspace/specs/auth/spec.yaml", AUTH_CODEX_SPEC_YAML); + } + FakeShell withDoneSpec() { return on( "find /home/dev/workspace/specs -mindepth 2 -maxdepth 2 -name spec.yaml -print", @@ -1144,6 +1187,7 @@ FakeShell withDoneSpec() { @Override public Result exec(List command) throws IOException { var joined = String.join(" ", command); + invocations.add(joined); for (var entry : failures.entrySet()) { if (joined.contains(entry.getKey())) { throw (IOException) entry.getValue(); @@ -1166,5 +1210,9 @@ public Result exec(List command, Path workDir, Duration timeout) throws public boolean isDryRun() { return false; } + + List invocations() { + return List.copyOf(invocations); + } } } diff --git a/sail-infra/src/test/java/ai/singlr/sail/commands/DispatchCommandTest.java b/sail-infra/src/test/java/ai/singlr/sail/commands/DispatchCommandTest.java index 7e05ad0..a41f8a6 100644 --- a/sail-infra/src/test/java/ai/singlr/sail/commands/DispatchCommandTest.java +++ b/sail-infra/src/test/java/ai/singlr/sail/commands/DispatchCommandTest.java @@ -137,6 +137,17 @@ void buildTaskPromptIncludesFullDescription() { assertTrue(prompt.contains("migrations")); } + @Test + void buildTaskPromptIncludesTargetAgent() { + var spec = + new Spec("ui", "Polish UI", "pending", null, List.of(), List.of("chorus"), "codex", null); + + var prompt = DispatchCommand.buildTaskPrompt(spec, "Details", "specs"); + + assertTrue(prompt.contains("Target repo: chorus")); + assertTrue(prompt.contains("Target agent: codex")); + } + @Test void dispatchCommandRegisteredInSing() { var cmd = new CommandLine(new Sail()); From 1bf0b67e988b7442f2fcf448208bc095a0020588 Mon Sep 17 00:00:00 2001 From: Uday Chandra Date: Thu, 7 May 2026 02:06:26 +0000 Subject: [PATCH 2/2] Support spec-level model routing --- README.md | 10 ++- .../main/java/ai/singlr/sail/config/Spec.java | 61 ++++++++++++++++++- .../ai/singlr/sail/config/SpecDirectory.java | 2 + .../java/ai/singlr/sail/engine/AgentCli.java | 27 +++++++- .../sail/gen/AgentContextGenerator.java | 6 ++ .../singlr/sail/gen/SpecSkillGenerator.java | 13 ++++ .../java/ai/singlr/sail/config/SpecTest.java | 24 ++++++++ .../ai/singlr/sail/engine/AgentSession.java | 28 ++++++++- .../singlr/sail/engine/AgentSessionTest.java | 26 ++++++++ .../java/ai/singlr/sail/api/ApiModels.java | 11 +++- .../ai/singlr/sail/api/SailApiOperations.java | 39 ++++++++++-- .../singlr/sail/commands/DispatchCommand.java | 25 +++++++- .../sail/commands/SpecCreateCommand.java | 22 +++++++ .../ai/singlr/sail/engine/SpecWorkspace.java | 2 + .../ai/singlr/sail/api/ApiRouterTest.java | 4 +- .../sail/api/SailApiOperationsTest.java | 11 +++- .../sail/commands/DispatchCommandTest.java | 14 ++++- 17 files changed, 306 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index b263b29..229cfcd 100644 --- a/README.md +++ b/README.md @@ -101,12 +101,15 @@ depends_on: - oauth-flow repo: webapp agent: codex +model: gpt-5.5 +reasoning_effort: high branch: feat/payment-integration ``` `repo`/`repos` routes work to the right repository in multi-repo projects. `agent` routes a spec to a specific installed agent (`claude-code`, `codex`, or `gemini`); if omitted, dispatch uses -`agent.type` from `sail.yaml`. +`agent.type` from `sail.yaml`. `model` and `reasoning_effort` tune agents that support those +controls; unsupported combinations fail fast. ### Lifecycle @@ -269,8 +272,9 @@ agent: ``` Claude, Codex, or Gemini can execute a spec. Set `agent: codex` in `spec.yaml` to override the -project default for that unit of work. The hooks fire at spec completion, not session stop — so -reviews always see complete, coherent work. +project default for that unit of work. For Codex, `model` and `reasoning_effort` map to +`codex exec --model ... --config model_reasoning_effort=...`. The hooks fire at spec completion, +not session stop — so reviews always see complete, coherent work. ## Example `sail.yaml` diff --git a/sail-core/src/main/java/ai/singlr/sail/config/Spec.java b/sail-core/src/main/java/ai/singlr/sail/config/Spec.java index b92bf05..d0ed4bd 100644 --- a/sail-core/src/main/java/ai/singlr/sail/config/Spec.java +++ b/sail-core/src/main/java/ai/singlr/sail/config/Spec.java @@ -11,6 +11,8 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; +import java.util.regex.Pattern; /** * A single spec representing one unit of work. Each spec lives in its own directory inside the @@ -24,6 +26,8 @@ * @param dependsOn IDs of specs that must be done first * @param repos repository paths this spec should branch and work in * @param agent agent CLI this spec should run with (nullable) + * @param model model this spec should run with (nullable) + * @param reasoningEffort model reasoning effort for this spec (nullable) * @param branch git branch for this spec's work (nullable) */ public record Spec( @@ -34,16 +38,33 @@ public record Spec( List dependsOn, List repos, String agent, + String model, + String reasoningEffort, String branch) { + private static final Pattern MODEL_PATTERN = Pattern.compile("[A-Za-z0-9._:/-]+"); + private static final Set REASONING_EFFORTS = + Set.of("none", "low", "medium", "high", "xhigh"); + + public Spec( + String id, + String title, + String status, + String assignee, + List dependsOn, + String branch) { + this(id, title, status, assignee, dependsOn, List.of(), null, null, null, branch); + } + public Spec( String id, String title, String status, String assignee, List dependsOn, + List repos, String branch) { - this(id, title, status, assignee, dependsOn, List.of(), null, branch); + this(id, title, status, assignee, dependsOn, repos, null, null, null, branch); } public Spec( @@ -53,8 +74,9 @@ public Spec( String assignee, List dependsOn, List repos, + String agent, String branch) { - this(id, title, status, assignee, dependsOn, repos, null, branch); + this(id, title, status, assignee, dependsOn, repos, agent, null, null, branch); } @SuppressWarnings("unchecked") @@ -70,6 +92,8 @@ public static Spec fromMap(Map map) { var dependsOn = (List) map.get("depends_on"); var repos = reposFromMap(map); var agent = validatedAgent((String) map.get("agent")); + var model = validatedModel((String) map.get("model")); + var reasoningEffort = validatedReasoningEffort((String) map.get("reasoning_effort")); var branch = (String) map.get("branch"); return new Spec( id, @@ -79,6 +103,8 @@ public static Spec fromMap(Map map) { dependsOn != null ? List.copyOf(dependsOn) : List.of(), repos, agent, + model, + reasoningEffort, branch); } @@ -103,6 +129,12 @@ public Map toMap() { if (agent != null) { map.put("agent", agent); } + if (model != null) { + map.put("model", model); + } + if (reasoningEffort != null) { + map.put("reasoning_effort", reasoningEffort); + } if (branch != null) { map.put("branch", branch); } @@ -136,4 +168,29 @@ private static String validatedAgent(String agent) { AgentCli.fromYamlName(agent); return agent; } + + private static String validatedModel(String model) { + if (model == null || model.isBlank()) { + return null; + } + if (!MODEL_PATTERN.matcher(model).matches()) { + throw new IllegalArgumentException( + "Invalid spec.model: '" + model + "'. Use a model id without spaces or shell syntax."); + } + return model; + } + + private static String validatedReasoningEffort(String reasoningEffort) { + if (reasoningEffort == null || reasoningEffort.isBlank()) { + return null; + } + if (!REASONING_EFFORTS.contains(reasoningEffort)) { + throw new IllegalArgumentException( + "Invalid spec.reasoning_effort: '" + + reasoningEffort + + "'. Must be one of: " + + String.join(", ", REASONING_EFFORTS)); + } + return reasoningEffort; + } } diff --git a/sail-core/src/main/java/ai/singlr/sail/config/SpecDirectory.java b/sail-core/src/main/java/ai/singlr/sail/config/SpecDirectory.java index b03762c..0b5b214 100644 --- a/sail-core/src/main/java/ai/singlr/sail/config/SpecDirectory.java +++ b/sail-core/src/main/java/ai/singlr/sail/config/SpecDirectory.java @@ -108,6 +108,8 @@ public static List updateStatus(List specs, String specId, String ne spec.dependsOn(), spec.repos(), spec.agent(), + spec.model(), + spec.reasoningEffort(), spec.branch()) : spec) .toList(); diff --git a/sail-core/src/main/java/ai/singlr/sail/engine/AgentCli.java b/sail-core/src/main/java/ai/singlr/sail/engine/AgentCli.java index 02ad7d1..3840064 100644 --- a/sail-core/src/main/java/ai/singlr/sail/engine/AgentCli.java +++ b/sail-core/src/main/java/ai/singlr/sail/engine/AgentCli.java @@ -95,17 +95,24 @@ public String displayName() { * @param fullPermissions whether to auto-approve all actions */ public String headlessCommand(String taskFile, boolean fullPermissions) { + return headlessCommand(taskFile, fullPermissions, null, null); + } + + public String headlessCommand( + String taskFile, boolean fullPermissions, String model, String reasoningEffort) { var task = "\"$(cat " + taskFile + ")\""; return switch (this) { case CLAUDE_CODE -> { + requireNoModelOptions(model, reasoningEffort); var perm = fullPermissions ? " --dangerously-skip-permissions" : ""; yield binaryName + " --print" + perm + " -p " + task; } case CODEX -> { var perm = fullPermissions ? " --full-auto" : ""; - yield binaryName + " exec" + perm + " " + task; + yield binaryName + " exec" + perm + codexModelOptions(model, reasoningEffort) + " " + task; } case GEMINI -> { + requireNoModelOptions(model, reasoningEffort); var perm = fullPermissions ? " --yolo" : ""; yield binaryName + perm + " -p " + task; } @@ -143,4 +150,22 @@ public static AgentCli fromYamlName(String name) { + "'. Known agents: claude-code, codex, gemini." + "\n Check the 'install' list in your sail.yaml agent section."); } + + private static String codexModelOptions(String model, String reasoningEffort) { + var options = new StringBuilder(); + if (model != null) { + options.append(" --model ").append(model); + } + if (reasoningEffort != null) { + options.append(" --config model_reasoning_effort='\"").append(reasoningEffort).append("\"'"); + } + return options.toString(); + } + + private void requireNoModelOptions(String model, String reasoningEffort) { + if (model != null || reasoningEffort != null) { + throw new IllegalArgumentException( + yamlName + " does not support spec-level model or reasoning_effort options yet."); + } + } } diff --git a/sail-core/src/main/java/ai/singlr/sail/gen/AgentContextGenerator.java b/sail-core/src/main/java/ai/singlr/sail/gen/AgentContextGenerator.java index b9320d9..633e2cc 100644 --- a/sail-core/src/main/java/ai/singlr/sail/gen/AgentContextGenerator.java +++ b/sail-core/src/main/java/ai/singlr/sail/gen/AgentContextGenerator.java @@ -475,6 +475,8 @@ private static void appendSpecSection(StringBuilder sb, SailYaml config) { depends_on: [] repo: app agent: codex + model: gpt-5.5 + reasoning_effort: high branch: feat/oauth-flow ``` @@ -486,6 +488,10 @@ private static void appendSpecSection(StringBuilder sb, SailYaml config) { a specific installed agent. If omitted, `sail spec dispatch` uses `agent.type` from `sail.yaml`. + Use `model` and `reasoning_effort` when the selected agent supports those controls. Codex + supports both; unsupported combinations fail during dispatch instead of silently falling + back. + ### Status Lifecycle `pending` → `in_progress` → `review` → `done` Spec status is managed by `sail`, not by you. Do not modify spec status directly during autonomous execution. diff --git a/sail-core/src/main/java/ai/singlr/sail/gen/SpecSkillGenerator.java b/sail-core/src/main/java/ai/singlr/sail/gen/SpecSkillGenerator.java index 399120b..4dacc02 100644 --- a/sail-core/src/main/java/ai/singlr/sail/gen/SpecSkillGenerator.java +++ b/sail-core/src/main/java/ai/singlr/sail/gen/SpecSkillGenerator.java @@ -244,6 +244,8 @@ private static String createInstructions(String specsDir) { depends_on: [] repo: agent: + model: + reasoning_effort: ``` 4. Write `%1$s//spec.md` using the spec template 5. Confirm: "Created spec `` — fill in the details in `%1$s//spec.md`" @@ -261,6 +263,10 @@ private static String createInstructions(String specsDir) { Ask which agent should execute the spec when the project has multiple installed agents. \ Use `agent: codex`, `agent: claude-code`, or `agent: gemini`. If omitted, Sail uses \ `agent.type` from `sail.yaml`. + + Ask which model and reasoning effort to use when the selected agent supports them. \ + For Codex, use `model: gpt-5.5` and `reasoning_effort: high` when the engineer wants \ + GPT-5.5 with high reasoning. """ .formatted(specsDir); } @@ -314,6 +320,8 @@ private static String coreReference(String specsDir) { depends_on: [] repo: app agent: codex + model: gpt-5.5 + reasoning_effort: high branch: feat/oauth-flow ``` @@ -338,6 +346,8 @@ private static String coreReference(String specsDir) { - **repo** (optional): single target repository path from `sail.yaml` `repos[].path` - **repos** (optional): list of target repository paths for cross-repo work - **agent** (optional): agent CLI for this spec (`claude-code`, `codex`, or `gemini`) + - **model** (optional): model id for agents that support model selection + - **reasoning_effort** (optional): `none`, `low`, `medium`, `high`, or `xhigh` - **branch** (optional): git branch name for this spec's work In multi-repo projects, always include `repo` or `repos` before dispatch so Sail can \ @@ -347,6 +357,9 @@ private static String coreReference(String specsDir) { In multi-agent projects, include `agent` when a spec should run on a non-default agent. \ If omitted, dispatch uses `agent.type` from `sail.yaml`. + Include `model` and `reasoning_effort` only for agents that support them. Sail passes \ + these to Codex and rejects unsupported combinations for other agents. + ### Directory Structure ``` %s/ diff --git a/sail-core/src/test/java/ai/singlr/sail/config/SpecTest.java b/sail-core/src/test/java/ai/singlr/sail/config/SpecTest.java index a47c9f9..3ba8e6d 100644 --- a/sail-core/src/test/java/ai/singlr/sail/config/SpecTest.java +++ b/sail-core/src/test/java/ai/singlr/sail/config/SpecTest.java @@ -119,6 +119,28 @@ void rejectsUnknownAgent() { () -> Spec.fromMap(Map.of("id", "auth", "agent", "unknown-agent"))); } + @Test + void parsesModelAndReasoningEffort() { + var spec = Spec.fromMap(Map.of("id", "auth", "model", "gpt-5.5", "reasoning_effort", "high")); + + assertEquals("gpt-5.5", spec.model()); + assertEquals("high", spec.reasoningEffort()); + } + + @Test + void rejectsUnsafeModel() { + assertThrows( + IllegalArgumentException.class, + () -> Spec.fromMap(Map.of("id", "auth", "model", "gpt-5.5; rm -rf /"))); + } + + @Test + void rejectsUnknownReasoningEffort() { + assertThrows( + IllegalArgumentException.class, + () -> Spec.fromMap(Map.of("id", "auth", "reasoning_effort", "huge"))); + } + @Test void toMapWritesSingleRepoAsRepo() { var spec = @@ -179,6 +201,8 @@ void roundTrips() { assertEquals(spec.dependsOn(), parsed.dependsOn()); assertEquals(spec.repos(), parsed.repos()); assertEquals(spec.agent(), parsed.agent()); + assertEquals(spec.model(), parsed.model()); + assertEquals(spec.reasoningEffort(), parsed.reasoningEffort()); assertEquals(spec.branch(), parsed.branch()); } diff --git a/sail-harness/src/main/java/ai/singlr/sail/engine/AgentSession.java b/sail-harness/src/main/java/ai/singlr/sail/engine/AgentSession.java index 66ada73..7aec718 100644 --- a/sail-harness/src/main/java/ai/singlr/sail/engine/AgentSession.java +++ b/sail-harness/src/main/java/ai/singlr/sail/engine/AgentSession.java @@ -157,8 +157,20 @@ public static List buildBackgroundLaunchCommand( String workDir, boolean fullPermissions, AgentCli agentCli) { + return buildBackgroundLaunchCommand( + containerName, sshUser, workDir, fullPermissions, agentCli, null, null); + } + + public static List buildBackgroundLaunchCommand( + String containerName, + String sshUser, + String workDir, + boolean fullPermissions, + AgentCli agentCli, + String model, + String reasoningEffort) { var cli = Objects.requireNonNullElse(agentCli, AgentCli.CLAUDE_CODE); - var agentCmd = cli.headlessCommand(TASK_FILE, fullPermissions); + var agentCmd = cli.headlessCommand(TASK_FILE, fullPermissions, model, reasoningEffort); var script = "mkdir -p \"$1\" && cd \"$2\" && bash -l -c \"$3\" > \"$4\" 2>&1 & echo $! > \"$5\""; return ContainerExec.asDevUser( @@ -178,8 +190,20 @@ public static List buildForegroundTaskCommand( String workDir, boolean fullPermissions, AgentCli agentCli) { + return buildForegroundTaskCommand( + containerName, sshUser, workDir, fullPermissions, agentCli, null, null); + } + + public static List buildForegroundTaskCommand( + String containerName, + String sshUser, + String workDir, + boolean fullPermissions, + AgentCli agentCli, + String model, + String reasoningEffort) { var cli = Objects.requireNonNullElse(agentCli, AgentCli.CLAUDE_CODE); - var agentCmd = cli.headlessCommand(TASK_FILE, fullPermissions); + var agentCmd = cli.headlessCommand(TASK_FILE, fullPermissions, model, reasoningEffort); var script = "cd \"$1\" && bash -l -c \"$2\""; return ContainerExec.asDevUser( containerName, List.of("bash", "-l", "-c", script, "bash", workDir, agentCmd)); diff --git a/sail-harness/src/test/java/ai/singlr/sail/engine/AgentSessionTest.java b/sail-harness/src/test/java/ai/singlr/sail/engine/AgentSessionTest.java index e5efe26..a97b84b 100644 --- a/sail-harness/src/test/java/ai/singlr/sail/engine/AgentSessionTest.java +++ b/sail-harness/src/test/java/ai/singlr/sail/engine/AgentSessionTest.java @@ -202,6 +202,32 @@ void buildBackgroundLaunchCommandCodexFullAuto() { assertTrue(joined.contains("codex exec --full-auto")); } + @Test + void buildBackgroundLaunchCommandCodexModelOptions() { + var cmd = + AgentSession.buildBackgroundLaunchCommand( + "acme", "dev", "/home/dev/workspace", true, AgentCli.CODEX, "gpt-5.5", "high"); + + var joined = String.join(" ", cmd); + assertTrue(joined.contains("codex exec --full-auto --model gpt-5.5")); + assertTrue(joined.contains("model_reasoning_effort='\"high\"'")); + } + + @Test + void buildBackgroundLaunchCommandRejectsUnsupportedModelOptions() { + assertThrows( + IllegalArgumentException.class, + () -> + AgentSession.buildBackgroundLaunchCommand( + "acme", + "dev", + "/home/dev/workspace", + true, + AgentCli.CLAUDE_CODE, + "gpt-5.5", + "high")); + } + @Test void buildBackgroundLaunchCommandGeminiUsesYolo() { var cmd = diff --git a/sail-infra/src/main/java/ai/singlr/sail/api/ApiModels.java b/sail-infra/src/main/java/ai/singlr/sail/api/ApiModels.java index ff736d2..36b01d5 100644 --- a/sail-infra/src/main/java/ai/singlr/sail/api/ApiModels.java +++ b/sail-infra/src/main/java/ai/singlr/sail/api/ApiModels.java @@ -103,6 +103,8 @@ record SpecView( List dependsOn, List repos, String agent, + String model, + String reasoningEffort, String branch, boolean ready, boolean blocked, @@ -115,7 +117,14 @@ record SpecView( } record DispatchedSpecView( - String id, String title, String status, List repos, String agent, String branch) { + String id, + String title, + String status, + List repos, + String agent, + String model, + String reasoningEffort, + String branch) { public DispatchedSpecView { repos = repos == null ? List.of() : List.copyOf(repos); } diff --git a/sail-infra/src/main/java/ai/singlr/sail/api/SailApiOperations.java b/sail-infra/src/main/java/ai/singlr/sail/api/SailApiOperations.java index 8194168..0a8228e 100644 --- a/sail-infra/src/main/java/ai/singlr/sail/api/SailApiOperations.java +++ b/sail-infra/src/main/java/ai/singlr/sail/api/SailApiOperations.java @@ -211,7 +211,7 @@ private DispatchResponse dispatchValue(String project, DispatchRequest request) var branchCreated = createBranchIfNeeded(project, loaded.config(), targetRepos, branch); if (!request.dryRun()) { - launchAgent(project, loaded.config(), task, branch, request.mode(), agentType); + launchAgent(project, loaded.config(), task, branch, request.mode(), taskSpec, agentType); } var status = request.dryRun() ? null : querySession(agentSession, project); @@ -406,6 +406,8 @@ private static SpecView specView(List specs, Spec spec) { spec.dependsOn(), spec.repos(), spec.agent(), + spec.model(), + spec.reasoningEffort(), spec.branch(), SpecDirectory.isReady(specs, spec), SpecDirectory.isBlocked(specs, spec), @@ -448,6 +450,8 @@ private static DispatchedSpecView dispatchedSpecView(Spec spec, String branch) { "in_progress", spec.repos(), spec.agent(), + spec.model(), + spec.reasoningEffort(), branch != null && !branch.isBlank() ? branch : null); } @@ -460,6 +464,8 @@ private static Spec withTargetRepos(Spec spec, List targetRepos) spec.dependsOn(), targetRepos.stream().map(SailYaml.Repo::path).toList(), spec.agent(), + spec.model(), + spec.reasoningEffort(), spec.branch()); } @@ -473,6 +479,11 @@ private static String buildTaskPrompt(Spec spec, String description) { + String.join(", ", spec.repos()) + "\n"; var targetAgent = spec.agent() == null ? "" : "\nTarget agent: " + spec.agent() + "\n"; + var targetModel = spec.model() == null ? "" : "\nTarget model: " + spec.model() + "\n"; + var targetReasoning = + spec.reasoningEffort() == null + ? "" + : "\nTarget reasoning effort: " + spec.reasoningEffort() + "\n"; return "Your current spec: \"" + spec.title() + "\" (id: " @@ -480,6 +491,8 @@ private static String buildTaskPrompt(Spec spec, String description) { + ")." + targetRepos + targetAgent + + targetModel + + targetReasoning + "\n" + description; } @@ -537,7 +550,13 @@ private boolean createBranchIfNeeded( } private void launchAgent( - String project, SailYaml config, String task, String branch, String mode, String agentType) { + String project, + SailYaml config, + String task, + String branch, + String mode, + Spec spec, + String agentType) { try { var session = new AgentSession(shell); session.ensureDirectory(project); @@ -548,9 +567,21 @@ private void launchAgent( var command = mode.equals("background") ? AgentSession.buildBackgroundLaunchCommand( - project, config.sshUser(), workDir, true, agentCli) + project, + config.sshUser(), + workDir, + true, + agentCli, + spec.model(), + spec.reasoningEffort()) : AgentSession.buildForegroundTaskCommand( - project, config.sshUser(), workDir, true, agentCli); + project, + config.sshUser(), + workDir, + true, + agentCli, + spec.model(), + spec.reasoningEffort()); var result = exec(command); if (!result.ok()) { throw new ApiException(ErrorCode.AGENT_LAUNCH_FAILED, "Failed to launch agent."); diff --git a/sail-infra/src/main/java/ai/singlr/sail/commands/DispatchCommand.java b/sail-infra/src/main/java/ai/singlr/sail/commands/DispatchCommand.java index 85c8774..7eec436 100644 --- a/sail-infra/src/main/java/ai/singlr/sail/commands/DispatchCommand.java +++ b/sail-infra/src/main/java/ai/singlr/sail/commands/DispatchCommand.java @@ -242,7 +242,13 @@ private void execute() throws Exception { if (background) { var sshCmd = AgentSession.buildBackgroundLaunchCommand( - name, sshUser, workDir, fullPermissions, agentCli); + name, + sshUser, + workDir, + fullPermissions, + agentCli, + taskSpec.model(), + taskSpec.reasoningEffort()); if (!json) { System.out.println(Ansi.AUTO.string(" @|bold Launching agent in background...|@")); System.out.println(Ansi.AUTO.string(" @|faint " + String.join(" ", sshCmd) + "|@")); @@ -261,7 +267,13 @@ private void execute() throws Exception { } else { var sshCmd = AgentSession.buildForegroundTaskCommand( - name, sshUser, workDir, fullPermissions, agentCli); + name, + sshUser, + workDir, + fullPermissions, + agentCli, + taskSpec.model(), + taskSpec.reasoningEffort()); if (!json) { System.out.println(Ansi.AUTO.string(" @|bold Launching agent with spec...|@")); System.out.println(Ansi.AUTO.string(" @|faint " + String.join(" ", sshCmd) + "|@")); @@ -300,6 +312,11 @@ static String buildTaskPrompt(Spec spec, String description, String specsDir) { + String.join(", ", spec.repos()) + "\n"; var targetAgent = spec.agent() == null ? "" : "\nTarget agent: " + spec.agent() + "\n"; + var targetModel = spec.model() == null ? "" : "\nTarget model: " + spec.model() + "\n"; + var targetReasoning = + spec.reasoningEffort() == null + ? "" + : "\nTarget reasoning effort: " + spec.reasoningEffort() + "\n"; return "Your current spec: \"" + spec.title() + "\" (id: " @@ -307,6 +324,8 @@ static String buildTaskPrompt(Spec spec, String description, String specsDir) { + ")." + targetRepos + targetAgent + + targetModel + + targetReasoning + "\n" + description; } @@ -320,6 +339,8 @@ private static Spec withTargetRepos(Spec spec, List targetRepos) spec.dependsOn(), targetRepos.stream().map(SailYaml.Repo::path).toList(), spec.agent(), + spec.model(), + spec.reasoningEffort(), spec.branch()); } diff --git a/sail-infra/src/main/java/ai/singlr/sail/commands/SpecCreateCommand.java b/sail-infra/src/main/java/ai/singlr/sail/commands/SpecCreateCommand.java index 365cff1..05974f2 100644 --- a/sail-infra/src/main/java/ai/singlr/sail/commands/SpecCreateCommand.java +++ b/sail-infra/src/main/java/ai/singlr/sail/commands/SpecCreateCommand.java @@ -57,6 +57,14 @@ public final class SpecCreateCommand implements Runnable { @Option(names = "--agent", description = "Agent CLI for this spec (claude-code, codex, gemini).") private String agent; + @Option(names = "--model", description = "Model id for this spec.") + private String model; + + @Option( + names = "--reasoning-effort", + description = "Reasoning effort for this spec (none, low, medium, high, xhigh).") + private String reasoningEffort; + @Option(names = "--depends-on", split = ",", description = "Comma-separated dependency ids.") private List dependsOn; @@ -92,6 +100,18 @@ private void execute() throws Exception { if (agent != null) { AgentCli.fromYamlName(agent); } + var routingMetadata = new LinkedHashMap(); + routingMetadata.put("id", resolvedSpecId); + if (agent != null) { + routingMetadata.put("agent", agent); + } + if (model != null) { + routingMetadata.put("model", model); + } + if (reasoningEffort != null) { + routingMetadata.put("reasoning_effort", reasoningEffort); + } + var routingProbe = Spec.fromMap(routingMetadata); var shell = new ShellExecutor(false); var mgr = new ContainerManager(shell); @@ -131,6 +151,8 @@ private void execute() throws Exception { resolvedDependsOn, resolvedRepos, agent, + routingProbe.model(), + routingProbe.reasoningEffort(), branch); workspace.createSpec(spec, SpecScaffold.markdownTemplate(title)); diff --git a/sail-infra/src/main/java/ai/singlr/sail/engine/SpecWorkspace.java b/sail-infra/src/main/java/ai/singlr/sail/engine/SpecWorkspace.java index 05a48ed..9f11fb0 100644 --- a/sail-infra/src/main/java/ai/singlr/sail/engine/SpecWorkspace.java +++ b/sail-infra/src/main/java/ai/singlr/sail/engine/SpecWorkspace.java @@ -86,6 +86,8 @@ public Spec updateStatus(String specId, String newStatus) spec.dependsOn(), spec.repos(), spec.agent(), + spec.model(), + spec.reasoningEffort(), spec.branch()); writeMetadata(updated); return updated; diff --git a/sail-infra/src/test/java/ai/singlr/sail/api/ApiRouterTest.java b/sail-infra/src/test/java/ai/singlr/sail/api/ApiRouterTest.java index 4c3f1e6..ae785ac 100644 --- a/sail-infra/src/test/java/ai/singlr/sail/api/ApiRouterTest.java +++ b/sail-infra/src/test/java/ai/singlr/sail/api/ApiRouterTest.java @@ -469,6 +469,8 @@ public Result spec(String project, String specId) { java.util.List.of(), null, null, + null, + null, true, false, java.util.List.of()), @@ -511,7 +513,7 @@ public Result dispatch(String project, DispatchRequest request true, null, new DispatchedSpecView( - request.specId(), "Spec", "in_progress", request.repos(), null, null), + request.specId(), "Spec", "in_progress", request.repos(), null, null, null, null), null, "", false)); diff --git a/sail-infra/src/test/java/ai/singlr/sail/api/SailApiOperationsTest.java b/sail-infra/src/test/java/ai/singlr/sail/api/SailApiOperationsTest.java index ad8123d..9fbc607 100644 --- a/sail-infra/src/test/java/ai/singlr/sail/api/SailApiOperationsTest.java +++ b/sail-infra/src/test/java/ai/singlr/sail/api/SailApiOperationsTest.java @@ -92,6 +92,8 @@ class SailApiOperationsTest { title: Add auth status: pending agent: codex + model: gpt-5.5 + reasoning_effort: high """; private static final String DONE_SPEC_YAML = @@ -584,17 +586,22 @@ void dispatchUsesSpecAgentWhenPresent() throws Exception { .on("mkdir -p /home/dev/workspace/specs", "") .on("printf '%s'", "") .on("mkdir -p /home/dev/.sail", "") - .on("codex exec --full-auto", ""); + .on("codex exec --full-auto --model gpt-5.5", ""); var operations = operations(baseYaml(), shell); var result = operations.dispatch("acme", request("auth")); assertEquals(true, get(result, "dispatched")); assertTrue(get(result, "spec").toString().contains("agent=codex")); + assertTrue(get(result, "spec").toString().contains("model=gpt-5.5")); + assertTrue(get(result, "spec").toString().contains("reasoning_effort=high")); assertTrue(get(result, "agent").toString().contains("type=codex")); assertTrue( shell.invocations().stream() - .anyMatch(command -> command.contains("codex exec --full-auto"))); + .anyMatch( + command -> + command.contains("codex exec --full-auto --model gpt-5.5") + && command.contains("model_reasoning_effort='\"high\"'"))); assertFalse( shell.invocations().stream().anyMatch(command -> command.contains("claude --print"))); } diff --git a/sail-infra/src/test/java/ai/singlr/sail/commands/DispatchCommandTest.java b/sail-infra/src/test/java/ai/singlr/sail/commands/DispatchCommandTest.java index a41f8a6..4348f7a 100644 --- a/sail-infra/src/test/java/ai/singlr/sail/commands/DispatchCommandTest.java +++ b/sail-infra/src/test/java/ai/singlr/sail/commands/DispatchCommandTest.java @@ -140,12 +140,24 @@ void buildTaskPromptIncludesFullDescription() { @Test void buildTaskPromptIncludesTargetAgent() { var spec = - new Spec("ui", "Polish UI", "pending", null, List.of(), List.of("chorus"), "codex", null); + new Spec( + "ui", + "Polish UI", + "pending", + null, + List.of(), + List.of("chorus"), + "codex", + "gpt-5.5", + "high", + null); var prompt = DispatchCommand.buildTaskPrompt(spec, "Details", "specs"); assertTrue(prompt.contains("Target repo: chorus")); assertTrue(prompt.contains("Target agent: codex")); + assertTrue(prompt.contains("Target model: gpt-5.5")); + assertTrue(prompt.contains("Target reasoning effort: high")); } @Test