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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# AgentFlow

Orchestrate codex, claude, and kimi agents in dependency graphs with parallel fanout, iterative cycles, and remote execution on SSH/EC2/ECS.
Orchestrate codex, claude, kimi, and gemini agents in dependency graphs with parallel fanout, iterative cycles, and remote execution on SSH/EC2/ECS.

![AgentFlow Graph](docs/graph.png)
*94-node pipeline: plan → 64 workers → 8 batch merges → 16 reviews → 4 review merges → synthesis*
Expand Down
2 changes: 2 additions & 0 deletions agentflow/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
claude,
codex,
fanout,
gemini,
kimi,
merge,
python_node,
Expand All @@ -26,6 +27,7 @@ def create_app(*args, **kwargs):
"claude",
"codex",
"fanout",
"gemini",
"kimi",
"merge",
"python_node",
Expand Down
67 changes: 67 additions & 0 deletions agentflow/agents/gemini.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
from __future__ import annotations

import os
from pathlib import Path

from agentflow.agents.base import AgentAdapter
from agentflow.env import merge_env_layers
from agentflow.prepared import ExecutionPaths, PreparedExecution
from agentflow.specs import NodeSpec, RepoInstructionsMode, ToolAccess


class GeminiAdapter(AgentAdapter):
def prepare(self, node: NodeSpec, prompt: str, paths: ExecutionPaths) -> PreparedExecution:
provider = self.provider_config(node.provider, node.agent)
executable = node.executable or "gemini"

# Use -p for non-interactive (headless) mode; positional prompt
# launches interactive mode which hangs in automation.
command = [
executable,
"-p",
prompt,
"--output-format",
"stream-json",
]

# Permission flags: map tools access to Gemini's approval model.
# --approval-mode plan = read-only (no writes allowed).
# --yolo = auto-approve all tool calls (required for non-interactive write).
if node.tools == ToolAccess.READ_ONLY:
command.extend(["--sandbox", "--approval-mode", "plan"])
else:
command.extend(["--yolo"])

if node.model:
command.extend(["--model", node.model])

runtime_files: dict[str, str] = {}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

node.mcps is silently ignored here. Unlike the Codex / Claude / Kimi adapters, the Gemini adapter never materializes an MCP config file and never passes any MCP-related flag or settings into the CLI, so a Gemini node configured with MCP servers runs without those tools.

I reproduced this by building a Gemini node with a stdio MCP server and calling GeminiAdapter.prepare(): the prepared command had no MCP arguments and runtime_files was empty, while the equivalent Kimi node produced a runtime config file and --mcp-config-file. Silently dropping configured MCP servers is a behavioral regression for workflows that rely on node.mcps.


repo_instructions_ignored = node.repo_instructions_mode == RepoInstructionsMode.IGNORE
if repo_instructions_ignored:
# Gemini reads GEMINI.md for repo instructions; running from runtime dir avoids it
pass

command.extend(node.extra_args)

env = merge_env_layers(getattr(provider, "env", None), node.env)
if provider:
if provider.api_key_env:
if provider.api_key_env in env:
api_key = env[provider.api_key_env]
else:
api_key = os.getenv(provider.api_key_env)
if api_key is not None:
env.setdefault("GEMINI_API_KEY", api_key)

cwd = paths.target_workdir
if repo_instructions_ignored:
cwd = str(Path(paths.target_runtime_dir))

return PreparedExecution(
command=command,
env=env,
cwd=cwd,
trace_kind="gemini",
runtime_files=runtime_files,
)
2 changes: 2 additions & 0 deletions agentflow/agents/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from agentflow.agents.base import AgentAdapter
from agentflow.agents.claude import ClaudeAdapter
from agentflow.agents.codex import CodexAdapter
from agentflow.agents.gemini import GeminiAdapter
from agentflow.agents.kimi import KimiAdapter
from agentflow.agents.util import PythonAdapter, ShellAdapter, SyncAdapter
from agentflow.specs import AgentKind
Expand All @@ -14,6 +15,7 @@ def __init__(self) -> None:
AgentKind.CODEX: CodexAdapter(),
AgentKind.CLAUDE: ClaudeAdapter(),
AgentKind.KIMI: KimiAdapter(),
AgentKind.GEMINI: GeminiAdapter(),
AgentKind.PYTHON: PythonAdapter(),
AgentKind.SHELL: ShellAdapter(),
AgentKind.SYNC: SyncAdapter(),
Expand Down
2 changes: 2 additions & 0 deletions agentflow/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,8 @@ def _provider_error_subject(pipeline_node: object | None) -> str:
return "Claude"
if agent_name == "kimi":
return "Kimi"
if agent_name == "gemini":
return "Gemini"
return "The agent"


Expand Down
13 changes: 12 additions & 1 deletion agentflow/cloud/installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
def agent_install_script(agents: list[str]) -> str:
"""Return a bash script that installs the requested agent CLIs.

Supported agents: codex, claude, kimi.
Supported agents: codex, claude, kimi, gemini.
"""
lines = ["#!/bin/bash", "set -euo pipefail", "export DEBIAN_FRONTEND=noninteractive", ""]

Expand Down Expand Up @@ -36,6 +36,11 @@ def agent_install_script(agents: list[str]) -> str:
lines.append("if ! command -v kimi &>/dev/null; then")
lines.append(" pip3 install kimi-cli || pip install kimi-cli")
lines.append("fi")
elif agent == "gemini":
lines.append("# Install Gemini CLI")
lines.append("if ! command -v gemini &>/dev/null; then")
lines.append(" npm install -g @google/gemini-cli")
lines.append("fi")
lines.append("")

lines.append("echo 'Agent installation complete'")
Expand All @@ -58,6 +63,8 @@ def agent_dockerfile(agents: list[str], base_image: str = "ubuntu:24.04") -> str
lines.append("RUN npm install -g @anthropic-ai/claude-code")
elif agent == "kimi":
lines.append("RUN pip3 install kimi-cli")
elif agent == "gemini":
lines.append("RUN npm install -g @google/gemini-cli")

lines.append("")
lines.append("WORKDIR /workspace")
Expand Down Expand Up @@ -109,5 +116,9 @@ def agent_auth_setup(agent: str, env: dict[str, str]) -> str:
if api_key:
parts.append(f"export KIMI_API_KEY={shlex.quote(api_key)}")
parts.append(f"export MOONSHOT_API_KEY={shlex.quote(api_key)}")
elif agent == "gemini":
api_key = env.get("GEMINI_API_KEY", "") or env.get("GOOGLE_API_KEY", "")
if api_key:
parts.append(f"export GEMINI_API_KEY={shlex.quote(api_key)}")

return " && ".join(parts) if parts else ""
134 changes: 134 additions & 0 deletions agentflow/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,8 @@ class LocalToolchainReport:
codex_version: str | None = None
claude_path: str | None = None
claude_version: str | None = None
gemini_path: str | None = None
gemini_version: str | None = None
detail: str | None = None

def as_dict(self) -> dict[str, object]:
Expand Down Expand Up @@ -422,6 +424,10 @@ def as_dict(self) -> dict[str, object]:
payload["claude_path"] = self.claude_path
if self.claude_version is not None:
payload["claude_version"] = self.claude_version
if self.gemini_path is not None:
payload["gemini_path"] = self.gemini_path
if self.gemini_version is not None:
payload["gemini_version"] = self.gemini_version
if self.detail is not None:
payload["detail"] = self.detail
return payload
Expand Down Expand Up @@ -572,6 +578,20 @@ def _local_kimi_ready_ok_check_detail(node_id: str, probe_command: str, executio
return detail + "."


def _local_gemini_ready_check_detail(node_id: str, executable: str) -> str:
return (
f"Node `{node_id}` (gemini) cannot launch local Gemini CLI after the node shell bootstrap; "
f"`{executable} --version` fails in the prepared local shell."
)


def _local_gemini_ready_ok_check_detail(node_id: str, executable: str) -> str:
return (
f"Node `{node_id}` (gemini) can launch local Gemini CLI after the node shell bootstrap; "
f"`{executable} --version` succeeds in the prepared local shell."
)


def _local_probe_timeout_detail(node_id: str, agent: str, command_text: str, timeout_seconds: float) -> str:
return (
f"Node `{node_id}` ({agent}) cannot finish the local preflight probe after the node shell bootstrap; "
Expand Down Expand Up @@ -1078,6 +1098,120 @@ def build_pipeline_local_kimi_readiness_info_checks(pipeline: object) -> list[Do
return checks


def _prepared_gemini_readiness_execution(
node: object,
pipeline: object | None = None,
) -> tuple[PreparedExecution, object, str] | None:
agent = _status_value(_object_value(node, "agent")).lower()
if agent != AgentKind.GEMINI.value:
return None

target = _coerce_local_target(_object_value(node, "target"))
if target is None:
return None

pipeline_workdir = _node_pipeline_workdir(node, pipeline)
paths = build_execution_paths(
base_dir=Path.cwd() / ".agentflow" / "doctor",
pipeline_workdir=pipeline_workdir,
run_id="doctor",
node_id=str(_object_value(node, "id", "gemini")),
node_target=target,
create_runtime_dir=False,
)
env = merge_env_layers(_object_value(None, "env"), _object_value(node, "env"))
executable = str(_object_value(node, "executable") or "gemini")
prepared = PreparedExecution(
command=[executable, "--version"],
env=env,
cwd=str(paths.host_workdir),
trace_kind="final",
)
return prepared, paths, executable


def _can_launch_local_gemini(node: object, pipeline: object | None = None) -> tuple[bool, str | None, str | None]:
prepared_with_paths = _prepared_gemini_readiness_execution(node, pipeline)
if prepared_with_paths is None:
return True, None, None

prepared, paths, executable = prepared_with_paths

try:
launch_plan = LocalRunner().plan_execution(
SimpleNamespace(target=_coerce_local_target(_object_value(node, "target"))),
prepared,
paths,
)
except (AttributeError, TypeError, ValidationError, ValueError):
return False, executable, None

env = os.environ.copy()
env.update(launch_plan.env)
try:
result = _run_doctor_subprocess(
launch_plan.command,
check=False,
capture_output=True,
cwd=launch_plan.cwd,
env=env,
text=True,
)
except OSError:
return False, executable, None
except _DoctorSubprocessTimeout as exc:
return False, executable, _local_probe_timeout_detail(
str(_object_value(node, "id", "gemini")),
AgentKind.GEMINI.value,
exc.command_text,
exc.timeout_seconds,
)
return result.returncode == 0, executable, None


def build_pipeline_local_gemini_readiness_checks(pipeline: object) -> list[DoctorCheck]:
checks: list[DoctorCheck] = []
for node in _object_value(pipeline, "nodes", []) or []:
agent = _status_value(_object_value(node, "agent")).lower()
if agent != AgentKind.GEMINI.value:
continue

ready, executable, failure_detail = _can_launch_local_gemini(node, pipeline)
if ready:
continue

node_id = str(_object_value(node, "id", "gemini"))
checks.append(
DoctorCheck(
name="gemini_ready",
status="failed",
detail=failure_detail or _local_gemini_ready_check_detail(node_id, executable or "gemini"),
)
)
return checks


def build_pipeline_local_gemini_readiness_info_checks(pipeline: object) -> list[DoctorCheck]:
checks: list[DoctorCheck] = []
for node in _object_value(pipeline, "nodes", []) or []:
if _prepared_gemini_readiness_execution(node, pipeline) is None:
continue

ready, executable, failure_detail = _can_launch_local_gemini(node, pipeline)
if not ready:
continue

node_id = str(_object_value(node, "id", "gemini"))
checks.append(
DoctorCheck(
name="gemini_ready",
status="ok",
detail=failure_detail or _local_gemini_ready_ok_check_detail(node_id, executable or "gemini"),
)
)
return checks


def build_pipeline_local_codex_readiness_checks(pipeline: object) -> list[DoctorCheck]:
checks: list[DoctorCheck] = []
for node in _object_value(pipeline, "nodes", []) or []:
Expand Down
4 changes: 4 additions & 0 deletions agentflow/dsl.py
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,10 @@ def kimi(*, task_id: str, prompt: str, **kwargs: Any) -> NodeBuilder:
return _node(AgentKind.KIMI, task_id=task_id, prompt=prompt, **kwargs)


def gemini(*, task_id: str, prompt: str, **kwargs: Any) -> NodeBuilder:
return _node(AgentKind.GEMINI, task_id=task_id, prompt=prompt, **kwargs)


def python_node(*, task_id: str, code: str, **kwargs: Any) -> NodeBuilder:
"""Run Python code directly. The ``code`` is executed as ``python3 -c <code>``."""
return _node(AgentKind.PYTHON, task_id=task_id, prompt=code, **kwargs)
Expand Down
6 changes: 6 additions & 0 deletions agentflow/specs.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ class AgentKind(StrEnum):
CODEX = "codex"
CLAUDE = "claude"
KIMI = "kimi"
GEMINI = "gemini"
PYTHON = "python"
SHELL = "shell"
SYNC = "sync"
Expand Down Expand Up @@ -221,6 +222,11 @@ def resolve_provider(value: str | ProviderConfig | None, agent: AgentKind) -> Pr
base_url="https://api.anthropic.com",
api_key_env="ANTHROPIC_API_KEY",
)
if alias in {"google", "gemini"} and agent == AgentKind.GEMINI:
return ProviderConfig(
name="google",
api_key_env="GEMINI_API_KEY",
)
if alias in {"kimi", "moonshot", "moonshot-ai"}:
if agent == AgentKind.CLAUDE:
return ProviderConfig(
Expand Down
Loading