Skip to content
Merged
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
110 changes: 95 additions & 15 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,27 +15,83 @@ inputs:
description: "OpenAI-compatible API key (required when provider is 'openai', 'deepseek', 'groq', 'together', or 'fireworks')"
required: false
default: ""
google_api_key:
description: "Google API key (required when provider is 'google')"
required: false
default: ""
github_token:
description: "GitHub token for posting review comments (defaults to github.token)"
required: true
provider:
description: "LLM provider to use (anthropic, openai, deepseek, groq, together, fireworks)"
description: "LLM provider for the reviewer (anthropic, openai, google, deepseek, groq, together, fireworks). Also the default for the explorer."
required: false
default: "anthropic"
image:
description: "Docker image to use. The default all-in-one image works with any provider; provider-specific variants are available for smaller footprint."
required: false
default: "ghcr.io/gauthierdmn/nominal-code:latest"
model:
description: "Model to use (e.g. claude-sonnet-4-20250514, gpt-4.1). Defaults to the provider's default model."
description: "Reviewer model (e.g. claude-sonnet-4-20250514, gpt-4.1). Empty uses the provider's default."
required: false
default: ""
max_turns:
description: "Maximum agentic turns for the reviewer (0 = unlimited)"
description: "Maximum agentic turns for the reviewer. Empty uses the bundled default (8), 0 means unlimited."
required: false
default: ""
explorer_provider:
description: "Explorer sub-agent provider. Empty inherits from `provider`."
required: false
default: ""
explorer_model:
description: "Explorer sub-agent model. Empty inherits from `model`. Useful for pairing a strong reviewer with a cheaper explorer."
required: false
default: "0"
default: ""
explorer_max_turns:
description: "Maximum turns for explorer sub-agents. Empty uses the bundled default (32), 0 means unlimited."
required: false
default: ""
prompt:
description: "Custom review instructions appended to the default prompt"
description: "Custom review instructions appended to the default reviewer prompt."
required: false
default: ""
coding_guidelines:
description: "Path to a coding guidelines file (relative to repo root)"
description: "Inline coding guidelines content. Appended to the reviewer system prompt."
required: false
default: ""
coding_guidelines_file:
description: "Path to a coding guidelines file (relative to repo root). Appended to the reviewer system prompt. Wins over `coding_guidelines` if both are set."
required: false
default: ""
reviewer_system_prompt:
description: "Inline reviewer system prompt that fully REPLACES the bundled prompt."
required: false
default: ""
reviewer_system_prompt_file:
description: "Path to a file whose contents fully REPLACE the bundled reviewer system prompt."
required: false
default: ""
explorer_system_prompt:
description: "Inline explorer sub-agent system prompt that fully REPLACES the bundled prompt."
required: false
default: ""
explorer_system_prompt_file:
description: "Path to a file whose contents fully REPLACE the bundled explorer system prompt."
required: false
default: ""
language_guidelines_dir:
description: "Directory containing per-language guideline files (e.g. python.md). Overrides the bundled language guidelines."
required: false
default: ""
inline_suggestions:
description: "Whether to render findings as inline code suggestions. Defaults to true; set 'false' to disable."
required: false
default: ""
ignore_existing_comments:
description: "When 'true', skip fetching prior PR comments so they don't bias the review (useful for re-runs)."
required: false
default: ""
dry_run:
description: "When 'true', run the full review pipeline but skip posting comments to the PR."
required: false
default: ""

Expand All @@ -46,27 +102,51 @@ runs:
env:
ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }}
OPENAI_API_KEY: ${{ inputs.openai_api_key }}
GOOGLE_API_KEY: ${{ inputs.google_api_key }}
GITHUB_TOKEN: ${{ inputs.github_token }}
AGENT_PROVIDER: ${{ inputs.provider }}
INPUT_MODEL: ${{ inputs.model }}
INPUT_MAX_TURNS: ${{ inputs.max_turns }}
AGENT_MODEL: ${{ inputs.model }}
AGENT_MAX_TURNS: ${{ inputs.max_turns }}
AGENT_EXPLORER_PROVIDER: ${{ inputs.explorer_provider }}
AGENT_EXPLORER_MODEL: ${{ inputs.explorer_model }}
AGENT_EXPLORER_MAX_TURNS: ${{ inputs.explorer_max_turns }}
INPUT_PROMPT: ${{ inputs.prompt }}
INPUT_CODING_GUIDELINES: ${{ inputs.coding_guidelines }}
CODING_GUIDELINES: ${{ inputs.coding_guidelines }}
CODING_GUIDELINES_FILE: ${{ inputs.coding_guidelines_file }}
REVIEWER_SYSTEM_PROMPT: ${{ inputs.reviewer_system_prompt }}
REVIEWER_SYSTEM_PROMPT_FILE: ${{ inputs.reviewer_system_prompt_file }}
EXPLORER_SYSTEM_PROMPT: ${{ inputs.explorer_system_prompt }}
EXPLORER_SYSTEM_PROMPT_FILE: ${{ inputs.explorer_system_prompt_file }}
LANGUAGE_GUIDELINES_DIR: ${{ inputs.language_guidelines_dir }}
INLINE_SUGGESTIONS: ${{ inputs.inline_suggestions }}
IGNORE_EXISTING_COMMENTS: ${{ inputs.ignore_existing_comments }}
DRY_RUN_REVIEW: ${{ inputs.dry_run }}
run: |
IMAGE="ghcr.io/gauthierdmn/nominal-code:latest"

docker run --rm \
-e GITHUB_ACTIONS=true \
-e GITHUB_EVENT_PATH=/github/event.json \
-e GITHUB_WORKSPACE=/github/workspace \
-e ANTHROPIC_API_KEY \
-e OPENAI_API_KEY \
-e GOOGLE_API_KEY \
-e GITHUB_TOKEN \
-e AGENT_PROVIDER \
-e INPUT_MODEL \
-e INPUT_MAX_TURNS \
-e AGENT_MODEL \
-e AGENT_MAX_TURNS \
-e AGENT_EXPLORER_PROVIDER \
-e AGENT_EXPLORER_MODEL \
-e AGENT_EXPLORER_MAX_TURNS \
-e INPUT_PROMPT \
-e INPUT_CODING_GUIDELINES \
-e CODING_GUIDELINES \
-e CODING_GUIDELINES_FILE \
-e REVIEWER_SYSTEM_PROMPT \
-e REVIEWER_SYSTEM_PROMPT_FILE \
-e EXPLORER_SYSTEM_PROMPT \
-e EXPLORER_SYSTEM_PROMPT_FILE \
-e LANGUAGE_GUIDELINES_DIR \
-e INLINE_SUGGESTIONS \
-e IGNORE_EXISTING_COMMENTS \
-e DRY_RUN_REVIEW \
-v "${GITHUB_EVENT_PATH}:/github/event.json:ro" \
-v "${GITHUB_WORKSPACE}:/github/workspace" \
"$IMAGE"
"${{ inputs.image }}"
11 changes: 8 additions & 3 deletions app/nominal_code/agent/api/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from nominal_code.agent.compaction import compact_with_notes
from nominal_code.agent.result import AgentResult
from nominal_code.agent.sub_agent import SubAgentConfig
from nominal_code.config.agent import UNLIMITED_TURNS
from nominal_code.conversation.base import truncate_messages
from nominal_code.llm.cost import CostSummary, build_cost_summary
from nominal_code.llm.messages import (
Expand Down Expand Up @@ -54,7 +55,7 @@ async def run_api_agent(
model: str,
provider: LLMProvider,
provider_name: ProviderName,
max_turns: int = 0,
max_turns: int = UNLIMITED_TURNS,
system_prompt: str = "",
allowed_tools: list[str] | None = None,
prior_messages: list[Message] | None = None,
Expand Down Expand Up @@ -144,7 +145,11 @@ async def run_api_agent(
tool_choice if turns == 0 else None
)

if has_submit_review and max_turns > 0 and turns == max_turns - 1:
if (
has_submit_review
and max_turns != UNLIMITED_TURNS
and turns == max_turns - 1
):
messages.append(
Message(
role="user",
Expand Down Expand Up @@ -274,7 +279,7 @@ async def run_api_agent(
context_window_tokens,
)

if max_turns > 0 and turns >= max_turns:
if max_turns != UNLIMITED_TURNS and turns >= max_turns:
logger.warning(
"Agent reached max turns (%d), stopping",
max_turns,
Expand Down
9 changes: 3 additions & 6 deletions app/nominal_code/agent/cli/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
)

from nominal_code.agent.result import AgentResult
from nominal_code.config.agent import UNLIMITED_TURNS
from nominal_code.llm.cost import CostSummary
from nominal_code.models import ErrorType, InvocationError, ProviderName

Expand Down Expand Up @@ -76,7 +77,7 @@ async def run_cli_agent(
prompt: str,
cwd: Path,
model: str | None = None,
max_turns: int = 0,
max_turns: int = UNLIMITED_TURNS,
cli_path: str | None = None,
conversation_id: str | None = None,
system_prompt: str = "",
Expand Down Expand Up @@ -112,7 +113,7 @@ async def run_cli_agent(
allowed_tools=allowed_tools or [],
cwd=cwd,
model=model,
max_turns=max_turns if max_turns > 0 else None,
max_turns=max_turns if max_turns != UNLIMITED_TURNS else None,
cli_path=cli_path,
resume=conversation_id,
system_prompt=system_prompt or None,
Expand Down Expand Up @@ -156,10 +157,6 @@ async def run_cli_agent(
model=options.model or "",
)

# On error the SDK puts the failure description in
# ``output``; route it to ``error.message`` and leave
# ``output`` empty so the success-vs-failure separation is
# structural rather than convention.
if message.is_error:
result = AgentResult(
output="",
Expand Down
3 changes: 2 additions & 1 deletion app/nominal_code/agent/invoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from nominal_code.agent.result import AgentResult
from nominal_code.agent.sub_agent import SubAgentConfig
from nominal_code.config import AgentConfig, ApiAgentConfig, CliAgentConfig
from nominal_code.config.agent import UNLIMITED_TURNS
from nominal_code.llm.messages import Message, ToolChoice
from nominal_code.llm.registry import create_provider
from nominal_code.platforms.base import PullRequestEvent
Expand Down Expand Up @@ -77,7 +78,7 @@ async def invoke_agent(
agent_config: AgentConfig | None = None,
conversation_id: str | None = None,
prior_messages: list[Message] | None = None,
max_turns: int = 0,
max_turns: int = UNLIMITED_TURNS,
tool_choice: ToolChoice | None = None,
notes_file_path: Path | None = None,
sub_agent_configs: dict[str, SubAgentConfig] | None = None,
Expand Down
27 changes: 11 additions & 16 deletions app/nominal_code/commands/ci/main.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
from __future__ import annotations

import logging
from pathlib import Path

from environs import Env

from nominal_code.config import Config, load_config
from nominal_code.llm.cost import format_cost_summary
from nominal_code.llm.cost import aggregate_cost_summary, format_cost_summary
from nominal_code.models import ProviderName
from nominal_code.platforms import build_platform
from nominal_code.platforms.base import PlatformName, PullRequestEvent
Expand Down Expand Up @@ -82,7 +81,12 @@ async def run_ci_review(platform_name: str) -> int:

return 1

cost_info: str = format_cost_summary(cost=result.cost)
cost_info: str = format_cost_summary(
cost=aggregate_cost_summary(
reviewer=result.cost,
sub_agents=result.sub_agent_costs,
),
)

logger.info(
"CI review posted for %s#%d (findings=%d)%s",
Expand Down Expand Up @@ -141,8 +145,9 @@ def _build_ci_config() -> Config:
"""
Build a CI Config from environment variables.

Reads ``INPUT_MODEL``, ``AGENT_PROVIDER``, and
``INPUT_CODING_GUIDELINES`` from the environment.
Reviewer model flows in via ``AGENT_MODEL`` and coding guidelines
via ``CODING_GUIDELINES_FILE`` through the standard env→YAML mapping
(see ``config/env.py``); only the provider default is forced here.

Returns:
Config: The resolved CI configuration.
Expand All @@ -151,14 +156,4 @@ def _build_ci_config() -> Config:
ValueError: If ``AGENT_PROVIDER`` is not a recognised provider.
"""

model_env: str = _env.str("INPUT_MODEL", "")
model: str | None = model_env if model_env else None

guidelines_env: str = _env.str("INPUT_CODING_GUIDELINES", "")
guidelines_path: Path | None = Path(guidelines_env) if guidelines_env else None

return load_config(
default_provider=ProviderName.ANTHROPIC,
model=model,
guidelines_path=guidelines_path,
)
return load_config(default_provider=ProviderName.ANTHROPIC)
4 changes: 4 additions & 0 deletions app/nominal_code/config/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

REVIEWER_DEFAULT_MAX_TURNS: int = 8
EXPLORER_DEFAULT_MAX_TURNS: int = 32
UNLIMITED_TURNS: int = 0


class AgentRoleConfig(BaseModel):
Expand Down Expand Up @@ -61,13 +62,16 @@ class CliAgentConfig(BaseModel):
cli_path (str | None): Path to the Claude Code CLI binary.
system_prompt (str): Reviewer system prompt text. Populated by the
config loader from ``settings.agent.reviewer.system_prompt``.
max_turns (int): Maximum agentic turns for the reviewer. Defaults
to ``0`` (unlimited).
"""

model_config = ConfigDict(frozen=True)

model: str | None = None
cli_path: str | None = None
system_prompt: str = ""
max_turns: int = UNLIMITED_TURNS


class ApiAgentConfig(BaseModel):
Expand Down
6 changes: 5 additions & 1 deletion app/nominal_code/config/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,11 @@
("INLINE_SUGGESTIONS", ["reviewer", "inline_suggestions"]),
("AGENT_PROVIDER", ["agent", "reviewer", "provider"]),
("AGENT_MODEL", ["agent", "reviewer", "model"]),
("AGENT_MAX_TURNS", ["agent", "reviewer", "max_turns"]),
("AGENT_CLI_PATH", ["agent", "cli_path"]),
("AGENT_EXPLORER_PROVIDER", ["agent", "explorer", "provider"]),
("AGENT_EXPLORER_MODEL", ["agent", "explorer", "model"]),
("AGENT_EXPLORER_MAX_TURNS", ["agent", "explorer", "max_turns"]),
("ALLOWED_USERS", ["access", "allowed_users"]),
("ALLOWED_REPOS", ["access", "allowed_repos"]),
("PR_TITLE_INCLUDE_TAGS", ["access", "pr_title_include_tags"]),
Expand Down Expand Up @@ -90,6 +92,8 @@
"K8S_BACKOFF_LIMIT",
"K8S_ACTIVE_DEADLINE_SECONDS",
"K8S_TTL_AFTER_FINISHED",
"AGENT_MAX_TURNS",
"AGENT_EXPLORER_MAX_TURNS",
}
)

Expand Down Expand Up @@ -174,7 +178,7 @@ def _collect_env_overrides() -> dict[str, Any]:
for env_name, path in ENV_MAP:
raw: str | None = os.environ.get(env_name)

if raw is None:
if not raw:
continue

value: str | int | bool | list[str]
Expand Down
4 changes: 4 additions & 0 deletions app/nominal_code/config/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from nominal_code.config.agent import (
EXPLORER_DEFAULT_MAX_TURNS,
REVIEWER_DEFAULT_MAX_TURNS,
UNLIMITED_TURNS,
AgentConfig,
AgentRoleConfig,
ApiAgentConfig,
Expand Down Expand Up @@ -361,6 +362,8 @@ def _build_agent(
)

if provider_name is None:
reviewer_max_turns: int = settings.agent.reviewer.max_turns or UNLIMITED_TURNS

return CliAgentConfig(
model=effective_model,
cli_path=settings.agent.cli_path,
Expand All @@ -369,6 +372,7 @@ def _build_agent(
file_path=settings.agent.reviewer.system_prompt_file,
default=load_prompt(REVIEWER_BUNDLED_PROMPT),
),
max_turns=reviewer_max_turns,
)

return _build_api_agent(
Expand Down
Loading
Loading