Skip to content

Commit ffeccd2

Browse files
feat: babysit support (#747)
* babysit support * Update pkg/agentrun-harness/tool/codex/codex.go Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * check context * fix codex * add gemini * add opencode * fix gemini * bump gemini * fix gemini Dockerfile * add gitAccessTokenEnv * add gitAccessTokenEnv * linter --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
1 parent aa8a55b commit ffeccd2

9 files changed

Lines changed: 275 additions & 34 deletions

File tree

.github/workflows/publish-agent-harness.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ jobs:
2626
env:
2727
NODE_VERSION: 24
2828
CLAUDE_VERSION: 2.1.72
29-
GEMINI_VERSION: 0.6.1
29+
GEMINI_VERSION: 0.36.0
3030
OPENCODE_VERSION: 1.2.24
3131
CODEX_VERSION: 0.104.0
3232
outputs:
@@ -172,7 +172,7 @@ jobs:
172172
- name: claude
173173
version: 2.1.72
174174
- name: gemini
175-
version: 0.6.1
175+
version: 0.36.0
176176
- name: opencode
177177
version: 1.2.24
178178
- name: codex

dockerfiles/agent-harness/gemini.Dockerfile

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,53 @@
11
ARG NODE_IMAGE_TAG=24
22
ARG NODE_IMAGE=node:${NODE_IMAGE_TAG}-slim
3-
ARG AGENT_VERSION=latest
3+
ARG AGENT_VERSION=0.36.0
44

55
ARG AGENT_HARNESS_BASE_IMAGE_TAG=latest
66
ARG AGENT_HARNESS_BASE_IMAGE_REPO=ghcr.io/pluralsh/agent-harness-base
77
ARG AGENT_HARNESS_BASE_IMAGE=$AGENT_HARNESS_BASE_IMAGE_REPO:$AGENT_HARNESS_BASE_IMAGE_TAG
88

9-
# Stage 1: Install Gemini CLI from npm in Chainguard Node image
9+
# Stage 1: Install Gemini CLI from npm in Node image
1010
FROM $NODE_IMAGE AS node
1111

12-
# Switch to root temporarily to install global packages
12+
# Re-declare ARGs after FROM so they are available in this stage
13+
ARG AGENT_VERSION
14+
1315
USER root
1416

1517
# Install Gemini CLI globally using npm
16-
RUN yarn global add @google/gemini-cli@$AGENT_VERSION
18+
RUN npm install -g @google/gemini-cli@$AGENT_VERSION
19+
20+
# Copy to a fixed, predictable path
21+
RUN cp -r $(npm root -g)/@google/gemini-cli /opt/gemini-cli
22+
23+
# Resolve the actual bin entry point from package.json and save it
24+
RUN node -e "\
25+
const pkg = require('/opt/gemini-cli/package.json'); \
26+
const bin = pkg.bin; \
27+
const rel = typeof bin === 'string' ? bin : (bin.gemini || bin['gemini-cli'] || Object.values(bin)[0]); \
28+
const abs = require('path').resolve('/opt/gemini-cli', rel); \
29+
process.stdout.write(abs);" > /opt/gemini-entry.txt && \
30+
echo "Gemini entry point: $(cat /opt/gemini-entry.txt)"
1731

18-
# Verify installation
19-
RUN gemini --version
32+
# Verify
33+
RUN node $(cat /opt/gemini-entry.txt) --version
2034

2135
# Stage 2: Copy Gemini CLI into agent-harness base
2236
FROM $AGENT_HARNESS_BASE_IMAGE AS final
2337

24-
# Copy the Gemini CLI from the Node.js image
25-
COPY --from=node /usr/local/share/.config/yarn/global /usr/local/share/.config/yarn/global
38+
# Copy the Gemini CLI module and resolved entry point from the Node.js image
39+
COPY --from=node /opt/gemini-cli /opt/gemini-cli
40+
COPY --from=node /opt/gemini-entry.txt /opt/gemini-entry.txt
2641

2742
# Copy Node.js runtime (needed to run the CLI)
2843
COPY --from=node /usr/local/bin/node /usr/local/bin/node
2944

30-
# Ensure proper ownership for nonroot user
45+
# Create wrapper script using the resolved entry point and set ownership
3146
USER root
32-
RUN ln -s /usr/local/share/.config/yarn/global/node_modules/@google/gemini-cli/dist/index.js /usr/local/bin/gemini
33-
RUN chown -R 65532:65532 /usr/local/share/.config/yarn/global /usr/local/bin/gemini /usr/local/bin/node
47+
RUN ENTRY=$(cat /opt/gemini-entry.txt) && \
48+
printf '#!/bin/sh\nexec /usr/local/bin/node %s "$@"\n' "$ENTRY" > /usr/local/bin/gemini && \
49+
chmod +x /usr/local/bin/gemini && \
50+
chown -R 65532:65532 /opt/gemini-cli /usr/local/bin/gemini /usr/local/bin/node
3451

3552
# Switch back to nonroot user
3653
USER 65532:65532

pkg/agentrun-harness/tool/codex/codex.go

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,11 @@ func (in *Codex) Run(ctx context.Context, options ...exec.Option) {
6363
go in.start(ctx, options...)
6464
}
6565

66+
func (in *Codex) ConfigureBabysitRun() error {
67+
klog.Info("configuring codex babysit run")
68+
return in.ConfigureSystemPromptForBabysitRun(console.AgentRuntimeTypeCodex)
69+
}
70+
6671
func (in *Codex) Configure(consoleURL, consoleToken, deployToken string) error {
6772
if err := in.ConfigureSystemPrompt(console.AgentRuntimeTypeCodex); err != nil {
6873
return err
@@ -153,7 +158,7 @@ func (in *Codex) Configure(consoleURL, consoleToken, deployToken string) error {
153158
Type: "stdio",
154159
Command: "/usr/local/bin/mcpserver",
155160
TrustPolicy: "always",
156-
EnabledTools: []string{"agentPullRequest", "createBranch", "fetchAgentRunTodos", "updateAgentRunTodos"},
161+
EnabledTools: []string{"agentPullRequest", "createBranch", "fetchAgentRunTodos", "updateAgentRunTodos", "getPRState", "getCILogs", "reactToComment", "createCommit"},
157162
Env: mcpBaseEnv,
158163
}}
159164
default:
@@ -201,6 +206,57 @@ func (in *Codex) OnMessage(f func(message *console.AgentMessageAttributes)) {
201206
in.onMessage = f
202207
}
203208

209+
func (in *Codex) BabysitRun(ctx context.Context, bCtx *v1.BabysitContext) bool {
210+
klog.V(log.LogLevelInfo).InfoS("starting codex babysit run", "agent_run_id", in.Config.Run.ID)
211+
if bCtx == nil {
212+
return false
213+
}
214+
215+
agent := "autonomous"
216+
args := []string{"exec", "--profile", agent, "--skip-git-repo-check", "--json", bCtx.Prompt}
217+
218+
in.executable = exec.NewExecutable(
219+
"codex",
220+
exec.WithArgs(args),
221+
exec.WithDir(in.Config.WorkDir),
222+
exec.WithEnv([]string{fmt.Sprintf("PLRL_CONSOLE_TOKEN=%s", in.consoleToken), fmt.Sprintf("CODEX_HOME=%s", path.Join(in.Config.WorkDir, ".codex"))}),
223+
exec.WithTimeout(in.Config.Run.Runtime.Config.Codex.Timeout),
224+
)
225+
226+
klog.V(log.LogLevelInfo).InfoS("codex executable configured", "timeout", in.Config.Run.Runtime.Config.Codex.Timeout)
227+
228+
// Send the initial prompt as a message too
229+
if in.onMessage != nil {
230+
in.onMessage(&console.AgentMessageAttributes{Message: bCtx.Prompt, Role: console.AiRoleUser})
231+
}
232+
233+
err := in.executable.RunStream(ctx, func(line []byte) {
234+
event := &StreamEvent{}
235+
if err := json.Unmarshal(line, event); err != nil {
236+
klog.V(log.LogLevelExtended).InfoS("failed to unmarshal codex stream event", "line", string(line))
237+
return
238+
}
239+
240+
if event.Type == "thread.started" && event.ThreadID != "" {
241+
in.threadID = event.ThreadID
242+
klog.V(log.LogLevelDebug).InfoS("codex thread started", "thread_id", in.threadID)
243+
}
244+
245+
msg := mapCodexStreamEventToAgentMessage(event, in.threadID)
246+
if in.onMessage != nil && msg != nil {
247+
in.onMessage(msg)
248+
}
249+
})
250+
if err != nil {
251+
klog.ErrorS(err, "codex execution failed")
252+
in.Config.ErrorChan <- err
253+
return false
254+
}
255+
256+
klog.V(log.LogLevelExtended).InfoS("codex babysit run finished")
257+
return false
258+
}
259+
204260
func (in *Codex) start(ctx context.Context, options ...exec.Option) {
205261
// In proxy mode the plural provider handles auth via PLRL_CONSOLE_TOKEN;
206262
// codex login is only needed for direct OpenAI usage.

pkg/agentrun-harness/tool/gemini/gemini.go

Lines changed: 71 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"encoding/json"
66
"fmt"
7+
"os"
78
"path"
89
"strings"
910

@@ -34,12 +35,73 @@ type Gemini struct {
3435
model Model
3536
}
3637

38+
func (in *Gemini) BabysitRun(ctx context.Context, bCtx *v1.BabysitContext) bool {
39+
if bCtx == nil {
40+
return false
41+
}
42+
43+
env := []string{fmt.Sprintf("GEMINI_API_KEY=%s", in.apiKey), fmt.Sprintf("GEMINI_CLI_TRUST_WORKSPACE=%s", "true")}
44+
if in.Config.Run.Runtime.Config.Gemini.Endpoint != nil {
45+
env = append(env, fmt.Sprintf("GEMINI_API_BASE_URL=%s", *in.Config.Run.Runtime.Config.Gemini.Endpoint))
46+
}
47+
48+
in.executable = exec.NewExecutable(
49+
"gemini",
50+
exec.WithArgs(in.args(bCtx.Prompt)),
51+
exec.WithDir(in.Config.WorkDir),
52+
exec.WithEnv(env),
53+
exec.WithTimeout(in.Config.Run.Runtime.Config.Gemini.Timeout),
54+
)
55+
56+
klog.V(log.LogLevelInfo).InfoS("Gemini executable configured", "timeout", in.Config.Run.Runtime.Config.Gemini.Timeout)
57+
58+
// Send the initial prompt as a message too
59+
if in.onMessage != nil {
60+
in.onMessage(&console.AgentMessageAttributes{Message: bCtx.Prompt, Role: console.AiRoleUser})
61+
}
62+
63+
err := in.executable.RunStream(ctx, func(line []byte) {
64+
klog.V(log.LogLevelTrace).InfoS("Gemini stream event", "line", string(line))
65+
66+
// This is here to prevent unavoidable log lines being reported as errors.
67+
// TODO: Remove once https://github.com/google-gemini/gemini-cli/issues/15053 is fixed.
68+
trimmed := strings.TrimSpace(string(line))
69+
if !strings.HasPrefix(trimmed, "{") {
70+
klog.V(log.LogLevelDebug).InfoS("ignoring non-json Gemini stream line", "trimmed", trimmed)
71+
return
72+
}
73+
74+
event := &events.EventBase{}
75+
if err := json.Unmarshal(line, event); err != nil {
76+
klog.ErrorS(err, "failed to unmarshal Gemini stream event", "line", line)
77+
in.Config.ErrorChan <- err
78+
return
79+
}
80+
81+
if err := event.OnMessage(line, in.onMessage); err != nil {
82+
klog.ErrorS(err, "failed to process Gemini stream event", "line", string(line))
83+
in.Config.ErrorChan <- err
84+
}
85+
})
86+
if err != nil {
87+
klog.ErrorS(err, "Gemini execution failed")
88+
in.Config.ErrorChan <- err
89+
return false
90+
}
91+
92+
return false
93+
}
94+
95+
func (in *Gemini) ConfigureBabysitRun() error {
96+
return in.ConfigureSystemPromptForBabysitRun(console.AgentRuntimeTypeGemini)
97+
}
98+
3799
func (in *Gemini) Run(ctx context.Context, options ...exec.Option) {
38100
go in.start(ctx, options...)
39101
}
40102

41103
func (in *Gemini) start(ctx context.Context, options ...exec.Option) {
42-
env := []string{fmt.Sprintf("GEMINI_API_KEY=%s", in.apiKey)}
104+
env := []string{fmt.Sprintf("GEMINI_API_KEY=%s", in.apiKey), fmt.Sprintf("GEMINI_CLI_TRUST_WORKSPACE=%s", "true")}
43105
if in.Config.Run.Runtime.Config.Gemini.Endpoint != nil {
44106
env = append(env, fmt.Sprintf("GEMINI_API_BASE_URL=%s", *in.Config.Run.Runtime.Config.Gemini.Endpoint))
45107
}
@@ -48,7 +110,7 @@ func (in *Gemini) start(ctx context.Context, options ...exec.Option) {
48110
"gemini",
49111
append(
50112
options,
51-
exec.WithArgs(in.args()),
113+
exec.WithArgs(in.args("")),
52114
exec.WithDir(in.Config.WorkDir),
53115
exec.WithEnv(env),
54116
exec.WithTimeout(in.Config.Run.Runtime.Config.Gemini.Timeout),
@@ -94,17 +156,20 @@ func (in *Gemini) start(ctx context.Context, options ...exec.Option) {
94156
// FinishedChan is closed by the controller after the babysit loop exits.
95157
}
96158

97-
func (in *Gemini) args() []string {
159+
func (in *Gemini) args(prompt string) []string {
160+
if len(prompt) > 0 {
161+
in.Config.Run.Prompt = prompt
162+
}
98163
if in.Config.Run.Mode == console.AgentRunModeWrite {
99164
return []string{
100165
"--approval-mode", "yolo",
101-
"--output-format", "stream-json",
166+
"--output-format", "stream-json", "--prompt",
102167
in.Config.Run.Prompt,
103168
}
104169
}
105170

106171
return []string{
107-
"--output-format", "stream-json",
172+
"--output-format", "stream-json", "--prompt",
108173
in.Config.Run.Prompt,
109174
}
110175
}
@@ -124,6 +189,7 @@ func (in *Gemini) Configure(consoleURL, consoleToken, deployToken string) error
124189
InactivityTimeout: int64(in.Config.Run.Runtime.Config.Gemini.InactivityTimeout.Seconds()),
125190
Model: in.model,
126191
ExaMcpConfigs: in.Config.Run.Runtime.ExaMcpConfigs,
192+
GitAccessToken: os.Getenv("GIT_ACCESS_TOKEN"),
127193
}
128194

129195
_, content, err := settings(input)

pkg/agentrun-harness/tool/gemini/settings.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ type ConfigTemplateInput struct {
2424
AgentRunMode console.AgentRunMode
2525
InactivityTimeout int64
2626
ExaMcpConfigs []agentrunv1.ExaMcpServerConfig
27+
GitAccessToken string
2728
}
2829

2930
func settings(input *ConfigTemplateInput) (fileName, content string, err error) {

pkg/agentrun-harness/tool/gemini/settings_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,61 @@ func TestSettingsTemplate_GenerateAndVerifyContents(t *testing.T) {
162162
})
163163
}
164164

165+
func TestSettingsTemplate_GitAccessToken(t *testing.T) {
166+
baseInput := &ConfigTemplateInput{
167+
Model: ModelGemini25Pro,
168+
RepositoryDir: "/repo",
169+
ConsoleURL: "https://console.test",
170+
ConsoleToken: "token",
171+
DeployToken: "deploy-token",
172+
AgentRunID: "run-123",
173+
AgentRunMode: console.AgentRunModeWrite,
174+
}
175+
176+
getGitAccessToken := func(t *testing.T, content string) string {
177+
t.Helper()
178+
var out map[string]any
179+
if err := json.Unmarshal([]byte(content), &out); err != nil {
180+
t.Fatalf("generated content is not valid JSON: %v\n%s", err, content)
181+
}
182+
mcpServers := out["mcpServers"].(map[string]any)
183+
plural := mcpServers["plural"].(map[string]any)
184+
env := plural["env"].(map[string]any)
185+
token, _ := env["GIT_ACCESS_TOKEN"].(string)
186+
return token
187+
}
188+
189+
t.Run("empty GitAccessToken renders empty string in env", func(t *testing.T) {
190+
input := *baseInput
191+
input.GitAccessToken = ""
192+
193+
_, content, err := settings(&input)
194+
if err != nil {
195+
t.Fatalf("settings() failed: %v", err)
196+
}
197+
198+
token := getGitAccessToken(t, content)
199+
if token != "" {
200+
t.Errorf("expected empty GIT_ACCESS_TOKEN, got %q", token)
201+
}
202+
})
203+
204+
t.Run("non-empty GitAccessToken is rendered in env", func(t *testing.T) {
205+
input := *baseInput
206+
input.GitAccessToken = "ghp_mytoken123"
207+
208+
_, content, err := settings(&input)
209+
if err != nil {
210+
t.Fatalf("settings() failed: %v", err)
211+
}
212+
213+
token := getGitAccessToken(t, content)
214+
if token != "ghp_mytoken123" {
215+
t.Errorf("expected GIT_ACCESS_TOKEN=ghp_mytoken123, got %q", token)
216+
}
217+
})
218+
}
219+
165220
func TestSettingsTemplate_ExaMcpServers(t *testing.T) {
166221
baseInput := &ConfigTemplateInput{
167222
Model: ModelGemini25Pro,

pkg/agentrun-harness/tool/gemini/templates/settings.json.gotmpl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
"PLRL_CONSOLE_TOKEN": "{{ .ConsoleToken }}",
5151
"PLRL_CONSOLE_URL": "{{ .ConsoleURL }}",
5252
"PLRL_AGENT_RUN_ID": "{{ .AgentRunID }}",
53+
"GIT_ACCESS_TOKEN": "{{ .GitAccessToken }}",
5354
{{ if eq .AgentRunMode "WRITE" }}
5455
"PLRL_EXCLUDE_TOOLS": "updateAgentRunAnalysis"
5556
{{ else }}

0 commit comments

Comments
 (0)