diff --git a/.github/actions/run-rix-job/action.yml b/.github/actions/run-rix-job/action.yml index 089daaac..3843df0f 100644 --- a/.github/actions/run-rix-job/action.yml +++ b/.github/actions/run-rix-job/action.yml @@ -15,7 +15,7 @@ inputs: description: GitHub PAT (or equivalent token) with read access to the target repo required: true agent: - description: "Coding agent to run: 'opencode' (default) or 'claude'" + description: "Coding agent to run: 'opencode' (default), 'claude', or 'pi'" required: false default: opencode model: @@ -28,7 +28,8 @@ inputs: description: >- Name of the environment variable agent-api-key is exported as. Must end in _API_KEY. Omit to use a default based on agent: OPENCODE_API_KEY for opencode, ANTHROPIC_API_KEY for - claude. + claude. Required for pi whenever agent-api-key is set, since pi has no single default + provider to fall back on. required: false max-tokens: description: Coding agent token budget cap @@ -87,6 +88,12 @@ runs: agent_normalized=$(printf '%s' "$RIX_AGENT" | tr '[:upper:]' '[:lower:]' | xargs) case "$agent_normalized" in claude) key_env=ANTHROPIC_API_KEY ;; + pi) + # pi is multi-provider with no single default credential, unlike opencode's + # own free-model provider — the caller must say which env var to use. + echo "::error::agent-api-key-env is required when agent=pi and agent-api-key is set" >&2 + exit 1 + ;; *) key_env=OPENCODE_API_KEY ;; esac fi diff --git a/.github/workflows/job.yml b/.github/workflows/job.yml index 9ce8b4d2..9e402cc9 100644 --- a/.github/workflows/job.yml +++ b/.github/workflows/job.yml @@ -14,7 +14,7 @@ on: required: true type: string agent: - description: "Coding agent to run: 'opencode' (default) or 'claude'" + description: "Coding agent to run: 'opencode' (default), 'claude', or 'pi'" required: false type: string default: opencode @@ -30,7 +30,8 @@ on: Name of the environment variable agent-api-key is exported as (e.g. OPENCODE_API_KEY, ANTHROPIC_API_KEY, OPENAI_API_KEY) — whatever the selected provider/model expects. Must end in _API_KEY. Omit to use a default based on agent: OPENCODE_API_KEY for - opencode, ANTHROPIC_API_KEY for claude. + opencode, ANTHROPIC_API_KEY for claude. Required for pi whenever agent-api-key is set, + since pi has no single default provider to fall back on. required: false type: string rix-version: diff --git a/README.md b/README.md index 6e099946..12262cbf 100644 --- a/README.md +++ b/README.md @@ -31,9 +31,9 @@ jobs: write-token: ${{ secrets.RIX_WRITE_TOKEN }} ``` -Pick the coding agent with the optional `agent` input — `opencode` (default) or `claude`. -Both install their CLI via npm at run time. With no `model` set, `opencode` picks its own -free default model, so the example above needs no `agent-api-key` at all. +Pick the coding agent with the optional `agent` input — `opencode` (default), `claude`, or +`pi`. All three install their CLI via npm at run time. With no `model` set, `opencode` picks +its own free default model, so the example above needs no `agent-api-key` at all. ### Using a different provider or model @@ -58,8 +58,13 @@ opencode supports many model providers beyond the free default. Pick a model wit `model` (if set) just picks among Claude's models, and `agent-api-key-env` defaults to `ANTHROPIC_API_KEY` automatically (no need to set it unless using a non-default env var name). -Local/self-hosted backends (e.g. Ollama, LM Studio) aren't supported yet — opencode reaches -those through a generated config file rather than a model string + API key, which is a +`pi` (the open-source Pi coding agent CLI) is multi-provider like opencode — pick a model +with `model`, forwarded verbatim as `--model` (e.g. `openai/gpt-4o`). Unlike opencode, pi has +no free default provider, so `agent-api-key-env` must always be set explicitly when +`agent-api-key` is provided; there is no per-agent default to fall back on. + +Local/self-hosted backends (e.g. Ollama, LM Studio) aren't supported yet — opencode and pi +reach those through a generated config file rather than a model string + API key, which is a separate mechanism this workflow doesn't build today. `@main` tracks the latest workflow; once a release is tagged, pin to that tag or a commit @@ -71,7 +76,7 @@ SHA (e.g. `...job.yml@v1.0.0`) for reproducible, supply-chain-safe runs. | --- | --- | | `read-token` | Required. PAT with read access to the target repo; used to clone it during the agent run. | | `write-token` | Required. PAT with `contents:write` + `pull-requests:write` on the target repo; used to push branches and open PRs. | -| `agent-api-key` | Optional. API key for the selected agent's model provider, exported as the env var named by `agent-api-key-env` (default `OPENCODE_API_KEY` for opencode, `ANTHROPIC_API_KEY` for claude). Not needed when leaving `model` unset, since opencode then picks its own free default model. | +| `agent-api-key` | Optional. API key for the selected agent's model provider, exported as the env var named by `agent-api-key-env` (default `OPENCODE_API_KEY` for opencode, `ANTHROPIC_API_KEY` for claude; for pi, `agent-api-key-env` must be set explicitly whenever `agent-api-key` is provided, since pi has no per-agent default). Not needed when leaving `model` unset, since opencode then picks its own free default model. | The read/write split keeps the agent run (which executes untrusted, model-generated work) on a read-only token; only the final, deterministic PR-creation step holds write access. diff --git a/src/Rix/Agents/AgentKind.cs b/src/Rix/Agents/AgentKind.cs index 41155588..d79a5375 100644 --- a/src/Rix/Agents/AgentKind.cs +++ b/src/Rix/Agents/AgentKind.cs @@ -8,6 +8,9 @@ internal enum AgentKind /// The open-source, multi-provider OpenCode CLI () — the default. OpenCode, + + /// The open-source, multi-provider Pi coding agent CLI (). + Pi, } internal static class AgentKindParser @@ -25,7 +28,8 @@ internal static ParseResult Parse(string value) { "claude" => new ParseSuccess(AgentKind.Claude), "opencode" => new ParseSuccess(AgentKind.OpenCode), - _ => new ParseError($"unknown agent '{normalized}' (expected 'claude' or 'opencode')"), + "pi" => new ParseSuccess(AgentKind.Pi), + _ => new ParseError($"unknown agent '{normalized}' (expected 'claude', 'opencode', or 'pi')"), }; } } diff --git a/src/Rix/Agents/PiAgent.cs b/src/Rix/Agents/PiAgent.cs new file mode 100644 index 00000000..0d343078 --- /dev/null +++ b/src/Rix/Agents/PiAgent.cs @@ -0,0 +1,81 @@ +using Rix.Job; +using Rix.Process; +using System.Text.Json; + +namespace Rix.Agents; + +/// +/// backed by the open-source Pi coding agent CLI: installs it via npm, +/// launches it in non-interactive JSON event mode, and reads cost from that stream. +/// +/// +/// Like , Pi is multi-provider — is +/// forwarded verbatim as --model (e.g. openai/gpt-4o), and the caller is responsible +/// for exporting whatever credential env var that provider expects. Pi has no per-run +/// output-token cap equivalent to CLAUDE_CODE_MAX_OUTPUT_TOKENS, so +/// is not forwarded and the invocation carries no environment +/// overrides. Unlike OpenCode, Pi does support --append-system-prompt, so rix's system +/// prompt is passed the same way as for . +/// +internal sealed class PiAgent : ICodingAgent +{ + private const string Package = "@earendil-works/pi-coding-agent"; + + public Task EnsureInstalledAsync(RunProcessAsync runProcess, CancellationToken cancellationToken) + => CodingAgentHelper.EnsureInstalledViaNpmAsync(runProcess, "pi", Package, cancellationToken); + + public AgentInvocation BuildInvocation(JobConfig config, string systemPrompt) + { + List args = + [ + "--mode", "json", config.Agent.Prompt, "--append-system-prompt", systemPrompt, + ]; + if (!string.IsNullOrWhiteSpace(config.Agent.Model)) + args.AddRange(["--model", config.Agent.Model]); + + return new + ( + FileName: "pi", + Arguments: args, + EnvironmentOverrides: new Dictionary() + ); + } + + /// + /// Reads cost from Pi's JSON event stream. Pi emits per-message cost (usage.cost.total + /// on each assistant message), not a single cumulative total, so this sums every assistant + /// message's cost from the agent_end event, which carries the full message list. + /// + /// + /// Only agent_end lines carry the message list; the caller's other stdout lines only + /// ever have agent_settled as the final line, so this reliably returns null + /// (reported cost 0) in normal operation today. + /// + public decimal? ParseCost(string outputLine) => CostLine.Read(outputLine, "\"agent_end\"", ReadCost); + + private static decimal? ReadCost(JsonElement root) + { + if (!root.TryGetProperty("messages", out var messages) || messages.ValueKind != JsonValueKind.Array) + return null; + + decimal total = 0m; + foreach (var message in messages.EnumerateArray()) + total += ReadAssistantCost(message); + return total; + } + + private static decimal ReadAssistantCost(JsonElement message) + { + if (!message.TryGetProperty("role", out var role) || role.GetString() != "assistant") + return 0m; + if (!message.TryGetProperty("usage", out var usage) || usage.ValueKind != JsonValueKind.Object) + return 0m; + if (!usage.TryGetProperty("cost", out var cost) || cost.ValueKind != JsonValueKind.Object) + return 0m; + if (!cost.TryGetProperty("total", out var totalCost) || totalCost.ValueKind != JsonValueKind.Number) + return 0m; + if (!totalCost.TryGetDecimal(out var v)) + return 0m; + return v; + } +} diff --git a/src/Rix/Cli/JobCommand.cs b/src/Rix/Cli/JobCommand.cs index aca249d4..f1b8523c 100644 --- a/src/Rix/Cli/JobCommand.cs +++ b/src/Rix/Cli/JobCommand.cs @@ -57,7 +57,7 @@ internal static class JobCommand private static readonly Option AgentOption = new ( name: "--agent", - description: "Coding agent to run: 'opencode' (default) or 'claude'" + description: "Coding agent to run: 'opencode' (default), 'claude', or 'pi'" ) { IsRequired = false }; diff --git a/src/Rix/Startup.cs b/src/Rix/Startup.cs index b67b4562..ed9db4de 100644 --- a/src/Rix/Startup.cs +++ b/src/Rix/Startup.cs @@ -31,6 +31,7 @@ private static ICodingAgent SelectAgent(AgentKind agent) { AgentKind.Claude => new ClaudeAgent(), AgentKind.OpenCode => new OpenCodeAgent(), + AgentKind.Pi => new PiAgent(), _ => throw new NotSupportedException($"Unsupported agent: {agent}"), }; diff --git a/tests/Rix.Tests/AgentKindParserTests.cs b/tests/Rix.Tests/AgentKindParserTests.cs index 3d53b7e3..00a79beb 100644 --- a/tests/Rix.Tests/AgentKindParserTests.cs +++ b/tests/Rix.Tests/AgentKindParserTests.cs @@ -17,6 +17,7 @@ public void Parse_IsCaseInsensitiveAndTrims() { Assert.AreEqual(AgentKind.Claude, Unwrap(AgentKindParser.Parse(" Claude "))); Assert.AreEqual(AgentKind.OpenCode, Unwrap(AgentKindParser.Parse("OPENCODE"))); + Assert.AreEqual(AgentKind.Pi, Unwrap(AgentKindParser.Parse(" PI "))); } [TestMethod] diff --git a/tests/Rix.Tests/JobConfigTests.cs b/tests/Rix.Tests/JobConfigTests.cs index 9c386c1d..c3f01a70 100644 --- a/tests/Rix.Tests/JobConfigTests.cs +++ b/tests/Rix.Tests/JobConfigTests.cs @@ -189,6 +189,7 @@ public void Create_DefaultsAgentToOpenCode_AndSelectsClaude() { Assert.AreEqual(Rix.Agents.AgentKind.OpenCode, Valid(Create()).Agent.Kind); Assert.AreEqual(Rix.Agents.AgentKind.Claude, Valid(Create(agent: "claude")).Agent.Kind); + Assert.AreEqual(Rix.Agents.AgentKind.Pi, Valid(Create(agent: "pi")).Agent.Kind); } [TestMethod] @@ -205,6 +206,7 @@ public void Create_DefaultsModelToNull_WhenAbsentOrBlank() Assert.IsNull(Valid(Create(agent: "opencode")).Agent.Model); Assert.IsNull(Valid(Create(agent: "opencode", model: "")).Agent.Model); Assert.IsNull(Valid(Create(agent: "claude")).Agent.Model); + Assert.IsNull(Valid(Create(agent: "pi")).Agent.Model); } [TestMethod] @@ -212,5 +214,6 @@ public void Create_PassesThroughExplicitModel() { Assert.AreEqual("openai/gpt-4o", Valid(Create(agent: "opencode", model: "openai/gpt-4o")).Agent.Model); Assert.AreEqual("claude-opus-4", Valid(Create(agent: "claude", model: "claude-opus-4")).Agent.Model); + Assert.AreEqual("openai/gpt-4o", Valid(Create(agent: "pi", model: "openai/gpt-4o")).Agent.Model); } } diff --git a/tests/Rix.Tests/PiAgentCostTests.cs b/tests/Rix.Tests/PiAgentCostTests.cs new file mode 100644 index 00000000..1569f3f3 --- /dev/null +++ b/tests/Rix.Tests/PiAgentCostTests.cs @@ -0,0 +1,73 @@ +using Rix.Agents; + +namespace Rix.Tests; + +[TestClass] +public class PiAgentCostTests +{ + [TestMethod] + public void ParseCost_SumsAssistantMessageCosts_FromAgentEnd() + { + const string line = """ + {"type":"agent_end","messages":[ + {"role":"user","content":"hi"}, + {"role":"assistant","usage":{"cost":{"input":0.01,"output":0.02,"total":0.03}}}, + {"role":"toolResult","content":"ok"}, + {"role":"assistant","usage":{"cost":{"input":0.04,"output":0.05,"total":0.09}}} + ]} + """; + + Assert.AreEqual(0.12m, new PiAgent().ParseCost(line)); + } + + [TestMethod] + public void ParseCost_ReturnsZero_WhenAgentEndHasNoCostedMessages() + { + const string line = """{"type":"agent_end","messages":[{"role":"user","content":"hi"}]}"""; + + Assert.AreEqual(0m, new PiAgent().ParseCost(line)); + } + + [TestMethod] + [DataRow("not json at all")] + [DataRow("""{"type":"message_end","messages":[]}""")] + [DataRow("{invalid json}")] + [DataRow("""{"type":123}""")] + [DataRow("[]")] + [DataRow("null")] + [DataRow("42")] + [DataRow("")] + public void ParseCost_ReturnsNull_ForNonAgentEndOrMalformedLines(string line) + { + Assert.IsNull(new PiAgent().ParseCost(line)); + } + + [TestMethod] + public void ParseCost_ReturnsNull_WhenAgentEndHasNoMessages() + { + const string line = """{"type":"agent_end"}"""; + + Assert.IsNull(new PiAgent().ParseCost(line)); + } + + [TestMethod] + public void ParseCost_IgnoresMessagesWithNonNumericCost() + { + const string line = """ + {"type":"agent_end","messages":[ + {"role":"assistant","usage":{"cost":{"total":"free"}}}, + {"role":"assistant","usage":{"cost":{"total":0.5}}} + ]} + """; + + Assert.AreEqual(0.5m, new PiAgent().ParseCost(line)); + } + + [TestMethod] + public void ParseCost_HandlesLeadingWhitespace() + { + const string line = """ {"type":"agent_end","messages":[{"role":"assistant","usage":{"cost":{"total":1.5}}}]}"""; + + Assert.AreEqual(1.5m, new PiAgent().ParseCost(line)); + } +} diff --git a/tests/Rix.Tests/PiAgentTests.cs b/tests/Rix.Tests/PiAgentTests.cs new file mode 100644 index 00000000..d28d0d01 --- /dev/null +++ b/tests/Rix.Tests/PiAgentTests.cs @@ -0,0 +1,158 @@ +using Rix.Agents; +using Rix.Job; +using Rix.Process; + +namespace Rix.Tests; + +[TestClass] +public class PiAgentTests +{ + private static readonly PiAgent Agent = new(); + + // Adapts a simple (fileName, args) -> ProcessResult stub to the full RunProcessAsync shape. + private static RunProcessAsync Runner(Func, Task> run) + => (fileName, args, _, _, _, _) => run(fileName, args); + + // ---- EnsureInstalledAsync ---- + + [TestMethod] + public async Task EnsureInstalled_ReturnsInstalled_WhenPiAlreadyInstalled() + { + var result = await Agent.EnsureInstalledAsync( + Runner((f, _) => Task.FromResult(f == "pi" + ? new ProcessSuccess() + : new ProcessFailure("exited with code 1"))), + CancellationToken.None); + + Assert.IsInstanceOfType(result); + } + + [TestMethod] + public async Task EnsureInstalled_ReturnsInstalled_WhenInstallSucceeds() + { + int piCallCount = 0; + var result = await Agent.EnsureInstalledAsync( + Runner((f, args) => f switch + { + "pi" => Task.FromResult(++piCallCount == 1 + ? new ProcessFailure("exited with code 1") + : new ProcessSuccess()), + "npm" => Task.FromResult(new ProcessSuccess()), + _ => throw new NotSupportedException(f), + }), + CancellationToken.None); + + Assert.IsInstanceOfType(result); + } + + [TestMethod] + public async Task EnsureInstalled_Fails_WhenNpmUnavailable() + { + var result = await Agent.EnsureInstalledAsync( + Runner((_, _) => Task.FromResult(new ProcessFailure("exited with code 1"))), + CancellationToken.None); + + Assert.IsInstanceOfType(result, out var failed); + StringAssert.Contains(failed.Reason, "npm"); + } + + [TestMethod] + public async Task EnsureInstalled_Fails_WhenNpmInstallFails() + { + var result = await Agent.EnsureInstalledAsync( + Runner((f, args) => f switch + { + "pi" => Task.FromResult(new ProcessFailure("exited with code 1")), + "npm" when args.Contains("--version") => Task.FromResult(new ProcessSuccess()), + "npm" => Task.FromResult(new ProcessFailure("exited with code 1")), + _ => throw new NotSupportedException(f), + }), + CancellationToken.None); + + Assert.IsInstanceOfType(result, out var failed); + StringAssert.Contains(failed.Reason, "npm install"); + } + + [TestMethod] + public async Task EnsureInstalled_InstallsPiPackage() + { + string? installedPackage = null; + int piCallCount = 0; + + await Agent.EnsureInstalledAsync( + Runner((f, args) => + { + if (f == "npm" && args.Contains("install")) + installedPackage = args.FirstOrDefault(a => a.StartsWith("@earendil-works/pi-coding-agent")); + return f switch + { + "pi" => Task.FromResult(++piCallCount == 1 + ? new ProcessFailure("exited with code 1") + : new ProcessSuccess()), + "npm" => Task.FromResult(new ProcessSuccess()), + _ => throw new NotSupportedException(f), + }; + }), + CancellationToken.None); + + Assert.AreEqual("@earendil-works/pi-coding-agent", installedPackage); + } + + [TestMethod] + public async Task EnsureInstalled_Fails_WhenPostInstallPiCheckFails() + { + var result = await Agent.EnsureInstalledAsync( + Runner((f, args) => f switch + { + "pi" => Task.FromResult(new ProcessFailure("exited with code 1")), + "npm" => Task.FromResult(new ProcessSuccess()), + _ => throw new NotSupportedException(f), + }), + CancellationToken.None); + + Assert.IsInstanceOfType(result, out var failed); + StringAssert.Contains(failed.Reason, "could not be verified"); + } + + // ---- BuildInvocation / ParseCost ---- + + [TestMethod] + public void BuildInvocation_ProducesPiJsonModeInvocation() + { + var config = TestConfig.Valid(agent: "pi", maxTokens: 1234); + + var invocation = Agent.BuildInvocation(config, "SYSTEM"); + + Assert.AreEqual("pi", invocation.FileName); + var args = invocation.Arguments.ToList(); + CollectionAssert.Contains(args, "--mode"); + CollectionAssert.Contains(args, "json"); + CollectionAssert.Contains(args, "--append-system-prompt"); + CollectionAssert.Contains(args, "SYSTEM"); + CollectionAssert.Contains(args, "do it"); + // Pi has no output-token cap equivalent, so no environment overrides are set. + Assert.AreEqual(0, invocation.EnvironmentOverrides.Count); + } + + [TestMethod] + public void BuildInvocation_OmitsModelFlag_ByDefault() + { + var config = TestConfig.Valid(agent: "pi"); + + var args = Agent.BuildInvocation(config, "SYSTEM").Arguments.ToList(); + + CollectionAssert.DoesNotContain(args, "--model"); + } + + [TestMethod] + public void BuildInvocation_IncludesModelFlag_WhenModelOverridden() + { + var config = TestConfig.Valid(agent: "pi", model: "openai/gpt-4o"); + + var args = Agent.BuildInvocation(config, "SYSTEM").Arguments.ToList(); + + var modelIndex = args.IndexOf("--model"); + Assert.AreNotEqual(-1, modelIndex); + Assert.AreEqual("openai/gpt-4o", args[modelIndex + 1]); + } +} diff --git a/tests/e2e/rix-job-e2e.bats b/tests/e2e/rix-job-e2e.bats index 554d6428..3babc6cd 100644 --- a/tests/e2e/rix-job-e2e.bats +++ b/tests/e2e/rix-job-e2e.bats @@ -1,7 +1,7 @@ #!/usr/bin/env bats # End-to-end tests for the rix binary itself - one level deeper than ci.yml's agent-plumbing job, # which only proves the composite-action/workflow plumbing works for the happy path. These tests -# invoke the built rix binary directly against the real opencode/claude CLIs (no npm version +# invoke the built rix binary directly against the real opencode/claude/pi CLIs (no npm version # pinning - see EnsureInstalledViaNpmAsync - so this is knowingly a bit brittle to upstream CLI # changes) and do real JSON parsing on the result. # @@ -26,6 +26,20 @@ # before ever calling the API). This rules out a false pass from some other JSON value (e.g. # a bare `null`) slipping past the weaker "parses as JSON" check. # +# pi doesn't fit either pattern above, and gets its own tests further down. Confirmed (by running +# the real CLI locally, both with no credentials at all and with an explicit invalid key) that: +# - With no provider resolvable at all, pi throws before ever starting a turn and its last +# stderr line (see ProcessWrapper.Diagnostic) is plain prose, not JSON - it's a doc pointer +# baked into the installed package (see auth-guidance.js's getProviderLoginHelp), e.g. +# ".../docs/models.md". So the structural check here is that it's a real file that ships with +# the CLI, not that it parses as JSON. +# - Unlike opencode/claude, an invalid key does not fail pi's process at all: pi embeds the +# failure in its own JSON event stream as a per-turn error (stopReason:"error", errorMessage) +# and still exits 0. So the "reached the CLI's own request/auth-failure protocol" case for pi +# looks like rix job *succeeding* with no PRs produced, not a JobFailure - the discriminator +# against a real regression (our flags rejected as unknown) is the same exit-0/success shape, +# since a rejected flag instead fails the process outright with a plain "Unknown option" line. +# # Requires: RIX_BIN (a built rix binary), RIX_REPO/RIX_READ_TOKEN (a real repo + token to clone), # and network access to install/run the real agent CLIs via npm. @@ -92,3 +106,24 @@ diagnostic_of() { echo "$diagnostic" | jq empty [ "$(echo "$diagnostic" | jq -r .is_error)" = true ] } + +@test "pi fails without a key, pointing at a real doc file it ships" { + export RIX_AGENT=pi + run "$RIX_BIN" job + [ "$status" -eq 1 ] + [ "$(result_field status)" = failure ] + # See the file header for why this is a .md path rather than JSON, and why it needs trimming. + diagnostic="$(result_field error | diagnostic_of | xargs)" + [[ "$diagnostic" == *.md ]] + [ -f "$diagnostic" ] +} + +@test "pi treats an invalid key as a successful run producing no PRs, not a failure" { + export RIX_AGENT=pi RIX_MODEL=openai/gpt-4o + export OPENAI_API_KEY=sk-invalid-0000000000000000000000000000000000000000 + run "$RIX_BIN" job + # See the file header: pi swallows a per-turn auth error into its JSON stream and still exits 0. + [ "$status" -eq 0 ] + [ "$(result_field status)" = success ] + [ "$(result_field pendingPrRequests | jq 'length')" = 0 ] +}