diff --git a/README.md b/README.md index 24e1f47..229cfcd 100644 --- a/README.md +++ b/README.md @@ -99,9 +99,18 @@ status: pending assignee: bob 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`. `model` and `reasoning_effort` tune agents that support those +controls; unsupported combinations fail fast. + ### Lifecycle ``` @@ -253,6 +262,7 @@ agent: type: claude-code install: - claude-code + - codex - gemini security_audit: enabled: true @@ -261,7 +271,10 @@ 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. 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 df859fe..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 @@ -5,11 +5,14 @@ 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; 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 @@ -22,6 +25,9 @@ * @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 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( @@ -31,8 +37,15 @@ public record Spec( String assignee, 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, @@ -40,7 +53,30 @@ 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, 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, repos, null, null, null, branch); + } + + public Spec( + String id, + String title, + String status, + String assignee, + List dependsOn, + List repos, + String agent, + String branch) { + this(id, title, status, assignee, dependsOn, repos, agent, null, null, branch); } @SuppressWarnings("unchecked") @@ -55,6 +91,9 @@ 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 model = validatedModel((String) map.get("model")); + var reasoningEffort = validatedReasoningEffort((String) map.get("reasoning_effort")); var branch = (String) map.get("branch"); return new Spec( id, @@ -63,6 +102,9 @@ public static Spec fromMap(Map map) { assignee, dependsOn != null ? List.copyOf(dependsOn) : List.of(), repos, + agent, + model, + reasoningEffort, branch); } @@ -84,6 +126,15 @@ public Map toMap() { } else if (!repos.isEmpty()) { map.put("repos", repos); } + 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); } @@ -109,4 +160,37 @@ 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; + } + + 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 1534212..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 @@ -107,6 +107,9 @@ public static List updateStatus(List specs, String specId, String ne spec.assignee(), 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 bfa017b..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 @@ -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,9 @@ private static void appendSpecSection(StringBuilder sb, SailYaml config) { assignee: claude-code depends_on: [] repo: app + agent: codex + model: gpt-5.5 + reasoning_effort: high branch: feat/oauth-flow ``` @@ -481,6 +484,14 @@ 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`. + + 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 d9a502b..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 @@ -243,6 +243,9 @@ private static String createInstructions(String specsDir) { status: pending 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`" @@ -256,6 +259,14 @@ 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`. + + 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); } @@ -308,6 +319,9 @@ private static String coreReference(String specsDir) { assignee: claude-code depends_on: [] repo: app + agent: codex + model: gpt-5.5 + reasoning_effort: high branch: feat/oauth-flow ``` @@ -331,12 +345,21 @@ 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`) + - **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 \ 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`. + + 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 b8a6d4b..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 @@ -105,6 +105,42 @@ 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 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 = @@ -164,6 +200,9 @@ void roundTrips() { assertEquals(spec.assignee(), parsed.assignee()); 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 85032fa..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 @@ -102,6 +102,9 @@ record SpecView( String assignee, List dependsOn, List repos, + String agent, + String model, + String reasoningEffort, String branch, boolean ready, boolean blocked, @@ -114,7 +117,14 @@ 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 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 ab6d86c..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 @@ -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(), taskSpec, 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,9 @@ private static SpecView specView(List specs, Spec spec) { spec.assignee(), spec.dependsOn(), spec.repos(), + spec.agent(), + spec.model(), + spec.reasoningEffort(), spec.branch(), SpecDirectory.isReady(specs, spec), SpecDirectory.isBlocked(specs, spec), @@ -445,6 +449,9 @@ private static DispatchedSpecView dispatchedSpecView(Spec spec, String branch) { spec.title(), "in_progress", spec.repos(), + spec.agent(), + spec.model(), + spec.reasoningEffort(), branch != null && !branch.isBlank() ? branch : null); } @@ -456,6 +463,9 @@ private static Spec withTargetRepos(Spec spec, List targetRepos) spec.assignee(), spec.dependsOn(), targetRepos.stream().map(SailYaml.Repo::path).toList(), + spec.agent(), + spec.model(), + spec.reasoningEffort(), spec.branch()); } @@ -468,12 +478,21 @@ 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: " + spec.id() + ")." + targetRepos + + targetAgent + + targetModel + + targetReasoning + "\n" + description; } @@ -531,20 +550,38 @@ 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, + Spec spec, + 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") ? 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."); @@ -608,9 +645,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..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 @@ -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; @@ -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) + "|@")); @@ -299,12 +311,21 @@ 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: " + spec.id() + ")." + targetRepos + + targetAgent + + targetModel + + targetReasoning + "\n" + description; } @@ -317,6 +338,9 @@ private static Spec withTargetRepos(Spec spec, List targetRepos) spec.assignee(), 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 53159aa..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 @@ -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,17 @@ 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 = "--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; @@ -85,6 +97,21 @@ 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 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); @@ -123,6 +150,9 @@ private void execute() throws Exception { assignee, 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 6a440f4..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 @@ -85,6 +85,9 @@ public Spec updateStatus(String specId, String newStatus) spec.assignee(), 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 2718763..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 @@ -468,6 +468,9 @@ public Result spec(String project, String specId) { java.util.List.of(), java.util.List.of(), null, + null, + null, + null, true, false, java.util.List.of()), @@ -510,7 +513,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, 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..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 @@ -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,16 @@ class SailApiOperationsTest { branch: feat/custom """; + private static final String AUTH_CODEX_SPEC_YAML = + """ + id: auth + title: Add auth + status: pending + agent: codex + model: gpt-5.5 + reasoning_effort: high + """; + private static final String DONE_SPEC_YAML = """ id: done @@ -564,6 +575,37 @@ 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 --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 --model gpt-5.5") + && command.contains("model_reasoning_effort='\"high\"'"))); + assertFalse( + shell.invocations().stream().anyMatch(command -> command.contains("claude --print"))); + } + @Test void dispatchLaunchesForegroundAgentAndReturnsSessionDetails() throws Exception { var operations = @@ -1103,6 +1145,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 +1177,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 +1194,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 +1217,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..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 @@ -137,6 +137,29 @@ void buildTaskPromptIncludesFullDescription() { assertTrue(prompt.contains("migrations")); } + @Test + void buildTaskPromptIncludesTargetAgent() { + var spec = + 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 void dispatchCommandRegisteredInSing() { var cmd = new CommandLine(new Sail());