Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions .github/actions/run-rix-job/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions .github/workflows/job.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
17 changes: 11 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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.
Expand Down
6 changes: 5 additions & 1 deletion src/Rix/Agents/AgentKind.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ internal enum AgentKind

/// <summary>The open-source, multi-provider OpenCode CLI (<see cref="OpenCodeAgent"/>) — the default.</summary>
OpenCode,

/// <summary>The open-source, multi-provider Pi coding agent CLI (<see cref="PiAgent"/>).</summary>
Pi,
}

internal static class AgentKindParser
Expand All @@ -25,7 +28,8 @@ internal static ParseResult<AgentKind> Parse(string value)
{
"claude" => new ParseSuccess<AgentKind>(AgentKind.Claude),
"opencode" => new ParseSuccess<AgentKind>(AgentKind.OpenCode),
_ => new ParseError<AgentKind>($"unknown agent '{normalized}' (expected 'claude' or 'opencode')"),
"pi" => new ParseSuccess<AgentKind>(AgentKind.Pi),
_ => new ParseError<AgentKind>($"unknown agent '{normalized}' (expected 'claude', 'opencode', or 'pi')"),
};
}
}
81 changes: 81 additions & 0 deletions src/Rix/Agents/PiAgent.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
using Rix.Job;
using Rix.Process;
using System.Text.Json;

namespace Rix.Agents;

/// <summary>
/// <see cref="ICodingAgent"/> 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.
/// </summary>
/// <remarks>
/// Like <see cref="OpenCodeAgent"/>, Pi is multi-provider — <see cref="AgentConfig.Model"/> is
/// forwarded verbatim as <c>--model</c> (e.g. <c>openai/gpt-4o</c>), and the caller is responsible
/// for exporting whatever credential env var that provider expects. Pi has no per-run
/// output-token cap equivalent to <c>CLAUDE_CODE_MAX_OUTPUT_TOKENS</c>, so
/// <see cref="AgentConfig.MaxTokens"/> is not forwarded and the invocation carries no environment
/// overrides. Unlike OpenCode, Pi does support <c>--append-system-prompt</c>, so rix's system
/// prompt is passed the same way as for <see cref="ClaudeAgent"/>.
/// </remarks>
internal sealed class PiAgent : ICodingAgent
{
private const string Package = "@earendil-works/pi-coding-agent";

public Task<InstallResult> EnsureInstalledAsync(RunProcessAsync runProcess, CancellationToken cancellationToken)
=> CodingAgentHelper.EnsureInstalledViaNpmAsync(runProcess, "pi", Package, cancellationToken);

public AgentInvocation BuildInvocation(JobConfig config, string systemPrompt)
{
List<string> 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<string, string>()
);
}

/// <summary>
/// Reads cost from Pi's JSON event stream. Pi emits per-message cost (<c>usage.cost.total</c>
/// on each assistant message), not a single cumulative total, so this sums every assistant
/// message's cost from the <c>agent_end</c> event, which carries the full message list.
/// </summary>
/// <remarks>
/// Only <c>agent_end</c> lines carry the message list; the caller's other stdout lines only
/// ever have <c>agent_settled</c> as the final line, so this reliably returns <c>null</c>
/// (reported cost <c>0</c>) in normal operation today.
/// </remarks>
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;
}
}
2 changes: 1 addition & 1 deletion src/Rix/Cli/JobCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ internal static class JobCommand
private static readonly Option<string> 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 };

Expand Down
1 change: 1 addition & 0 deletions src/Rix/Startup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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}"),
};

Expand Down
1 change: 1 addition & 0 deletions tests/Rix.Tests/AgentKindParserTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
3 changes: 3 additions & 0 deletions tests/Rix.Tests/JobConfigTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -205,12 +206,14 @@ 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]
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);
}
}
73 changes: 73 additions & 0 deletions tests/Rix.Tests/PiAgentCostTests.cs
Original file line number Diff line number Diff line change
@@ -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));
}

Comment thread
Tim-Pohlmann marked this conversation as resolved.
[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));
}
}
Loading
Loading