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
10 changes: 8 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@ OPENROUTER_API_KEY=
# Optional: API key required when the control plane enforces auth
AGENTFIELD_API_KEY=

# Optional model/provider overrides
HARNESS_PROVIDER=opencode
# AForge exec is the default. Set HARNESS_PROVIDER=opencode to roll back —
# OpenCode stays installed in the image.
HARNESS_PROVIDER=aforge
# Which aforge subcommand the SDK runs: `exec` (default, direct one-shot) or
# `do` (routed workflow). Honoured by agentfield >= 0.1.130.
AGENTFIELD_AFORGE_COMMAND=exec
# Override which aforge binary runs (honoured today via HarnessConfig.aforge_bin).
# CONTRACT_AF_AFORGE_BIN=/absolute/path/to/aforge
HARNESS_MODEL=openrouter/moonshotai/kimi-k2.5
AI_MODEL=openrouter/moonshotai/kimi-k2.5
43 changes: 41 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,3 +1,40 @@
# AForge CLI — fetched from the public release mirror and verified against the
# release checksums (which hash the *uncompressed* binaries). Both ARGs are
# overridable so CI or a local mirror can serve the same layout elsewhere:
# docker build --build-arg AFORGE_BASE_URL=... --build-arg AFORGE_VERSION=... .
ARG AFORGE_BASE_URL=https://agentfield.ai/downloads/aforge
ARG AFORGE_VERSION=v0.1.0

# Reuses the python:3.11-slim base (debian bookworm) already pulled for the
# builder/runtime stages rather than adding a second base image to the build.
FROM python:3.11-slim AS aforge

ARG AFORGE_BASE_URL
ARG AFORGE_VERSION
# TARGETARCH is populated by BuildKit; dpkg is the fallback for the legacy
# builder, where the build platform is always the target platform.
ARG TARGETARCH

RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
curl && \
rm -rf /var/lib/apt/lists/*

WORKDIR /out

RUN set -eu; \
arch="${TARGETARCH:-$(dpkg --print-architecture)}"; \
curl -fsSL "${AFORGE_BASE_URL}/${AFORGE_VERSION}/aforge-linux-${arch}.gz" -o aforge.gz; \
gunzip -c aforge.gz > aforge; \
rm aforge.gz; \
curl -fsSL "${AFORGE_BASE_URL}/${AFORGE_VERSION}/checksums.txt" -o checksums.txt; \
grep " aforge-linux-${arch}$" checksums.txt \
| sed "s/ aforge-linux-.*/ aforge/" \
| sha256sum -c -; \
rm checksums.txt; \
chmod +x aforge


FROM python:3.11-slim AS builder

ENV PYTHONDONTWRITEBYTECODE=1 \
Expand All @@ -14,7 +51,7 @@ COPY pyproject.toml README.md ./
COPY src/ src/

RUN pip install --no-cache-dir --prefix=/install \
"agentfield>=0.1.47" \
"agentfield>=0.1.130" \
"pydantic>=2.0" \
"httpx>=0.27" \
"python-dotenv>=1.0" \
Expand All @@ -28,7 +65,8 @@ FROM python:3.11-slim AS runtime
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
AGENTFIELD_SERVER=http://agentfield:8080 \
HARNESS_PROVIDER=opencode \
HARNESS_PROVIDER=aforge \
AGENTFIELD_AFORGE_COMMAND=exec \
HARNESS_MODEL=openrouter/moonshotai/kimi-k2.5 \
AI_MODEL=openrouter/moonshotai/kimi-k2.5 \
PORT=8004 \
Expand All @@ -54,6 +92,7 @@ RUN mkdir -p /home/contractaf/.config/opencode && \
chown -R contractaf:contractaf /home/contractaf/.config

COPY --from=builder /install /usr/local
COPY --from=aforge /out/aforge /usr/local/bin/aforge
COPY src/ /app/src/

USER contractaf
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,12 @@ docker compose up --build

Starts AgentField control plane (`http://localhost:8080`) + Contract-AF agent.

AForge `exec` is the default coding harness. The image fetches the released
`aforge` binary from `https://agentfield.ai/downloads/aforge` at build time and
verifies it against the release checksums. OpenCode stays installed as the
configuration-only rollback: set `HARNESS_PROVIDER=opencode`. Point
`CONTRACT_AF_AFORGE_BIN` at another binary to override which `aforge` runs.

```bash
curl -X POST http://localhost:8080/api/v1/execute/async/contract-af.analyze \
-H "Content-Type: application/json" \
Expand Down
58 changes: 58 additions & 0 deletions agentfield-package.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
config_version: v1 # manifest schema version (omit = legacy v0)
name: contract-af
version: 0.1.0
description: AI-native legal contract risk analyzer — finds dangerous clauses, proves exploitability
author: Agent-Field

entrypoint:
start: python -m contract_af.app
healthcheck: /health

agent_node:
node_id: contract-af
default_port: 8004

dependencies:
system:
# `af install` builds a Python venv; it does not fetch harness CLIs (the
# Docker image does). Printed as a manual step so a local install says so
# up front instead of failing on the first harness call.
- "aforge (https://agentfield.ai/downloads/aforge) — or set HARNESS_PROVIDER=opencode"

user_environment:
required:
# Drives both the coding harness (AForge/OpenCode) and the .ai() reasoning
# calls — every phase of the pipeline needs it.
- name: OPENROUTER_API_KEY
description: LLM provider key (OpenRouter)
type: secret
scope: global
optional:
- name: AGENTFIELD_SERVER
description: Control-plane URL
default: http://localhost:8080
- name: AGENTFIELD_API_KEY
description: Control-plane API key (if auth is enabled)
type: secret
scope: global
- name: NODE_ID
description: Identity this node registers under
default: contract-af
# AForge is the default harness. OpenCode stays installed as the
# configuration-only rollback: set this to `opencode`.
- name: HARNESS_PROVIDER
description: Coding-agent harness provider (aforge | opencode)
default: aforge
- name: HARNESS_MODEL
description: Model the coding harness uses
default: openrouter/moonshotai/kimi-k2.5
- name: AI_MODEL
description: Model the .ai() reasoning calls use
default: openrouter/moonshotai/kimi-k2.5
- name: CONTRACT_AF_AFORGE_BIN
description: Path to the aforge binary (defaults to `aforge` on PATH)
- name: CONTRACT_AF_OPENCODE_BIN
description: Path to the opencode binary (defaults to `opencode` on PATH)
- name: CONTRACT_AF_MAX_TURNS
description: Maximum harness iterations per call
default: "50"
3 changes: 2 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ services:
- AGENTFIELD_SERVER=http://agentfield:8080
- AGENTFIELD_API_KEY=${AGENTFIELD_API_KEY:-}
- AGENT_CALLBACK_URL=http://contract-af:8004
- HARNESS_PROVIDER=opencode
- HARNESS_PROVIDER=${HARNESS_PROVIDER:-aforge}
- AGENTFIELD_AFORGE_COMMAND=${AGENTFIELD_AFORGE_COMMAND:-exec}
- HARNESS_MODEL=${HARNESS_MODEL:-openrouter/moonshotai/kimi-k2.5}
- AI_MODEL=${AI_MODEL:-openrouter/moonshotai/kimi-k2.5}
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY}
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ version = "0.1.0"
description = "Legal contract risk analyzer — finds dangerous clauses, proves exploitability"
requires-python = ">=3.10"
dependencies = [
"agentfield",
"agentfield>=0.1.130",
"pydantic>=2.0",
"fastapi>=0.100",
"uvicorn>=0.20",
Expand Down
1 change: 1 addition & 0 deletions src/contract_af/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
max_turns=_ai_config.max_turns,
env=_ai_config.provider_env(),
opencode_bin=_ai_config.opencode_bin,
aforge_bin=_ai_config.aforge_bin,
permission_mode="auto",
),
ai_config=AIConfig(
Expand Down
10 changes: 9 additions & 1 deletion src/contract_af/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
class AIIntegrationConfig(BaseModel):
provider: str = Field(
default_factory=lambda: os.getenv(
"CONTRACT_AF_PROVIDER", os.getenv("HARNESS_PROVIDER", "opencode")
"CONTRACT_AF_PROVIDER", os.getenv("HARNESS_PROVIDER", "aforge")
)
)
harness_model: str = Field(
Expand All @@ -33,6 +33,11 @@ class AIIntegrationConfig(BaseModel):
opencode_bin: str = Field(
default_factory=lambda: os.getenv("CONTRACT_AF_OPENCODE_BIN", "opencode")
)
aforge_bin: str = Field(
default_factory=lambda: os.getenv(
"CONTRACT_AF_AFORGE_BIN", os.getenv("AFORGE_BIN", "aforge")
)
)
opencode_server: str | None = Field(
default_factory=lambda: os.getenv(
"CONTRACT_AF_OPENCODE_SERVER", os.getenv("OPENCODE_SERVER")
Expand All @@ -53,6 +58,9 @@ def provider_env(self) -> dict[str, str]:
env: dict[str, str] = {
key: value for key in env_keys if (value := os.getenv(key))
}
env["AGENTFIELD_AFORGE_COMMAND"] = os.getenv(
"AGENTFIELD_AFORGE_COMMAND", "exec"
)
xdg = os.getenv("XDG_DATA_HOME") or os.path.join(
tempfile.gettempdir(), "opencode-shared-data"
)
Expand Down
49 changes: 49 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
from __future__ import annotations

import pytest

from contract_af.config import AIIntegrationConfig


def test_aforge_exec_is_the_default_harness(monkeypatch: pytest.MonkeyPatch) -> None:
for key in (
"CONTRACT_AF_PROVIDER",
"HARNESS_PROVIDER",
"CONTRACT_AF_AFORGE_BIN",
"AFORGE_BIN",
"AGENTFIELD_AFORGE_COMMAND",
):
monkeypatch.delenv(key, raising=False)

config = AIIntegrationConfig.from_env()

assert config.provider == "aforge"
assert config.aforge_bin == "aforge"
assert config.provider_env()["AGENTFIELD_AFORGE_COMMAND"] == "exec"


def test_opencode_remains_an_explicit_rollback(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("HARNESS_PROVIDER", "opencode")

assert AIIntegrationConfig.from_env().provider == "opencode"


def test_aforge_bin_override_precedence(monkeypatch: pytest.MonkeyPatch) -> None:
"""CONTRACT_AF_AFORGE_BIN wins over AFORGE_BIN, which wins over the default."""
monkeypatch.delenv("CONTRACT_AF_AFORGE_BIN", raising=False)
monkeypatch.setenv("AFORGE_BIN", "/opt/aforge/aforge")

assert AIIntegrationConfig.from_env().aforge_bin == "/opt/aforge/aforge"

monkeypatch.setenv("CONTRACT_AF_AFORGE_BIN", "/usr/local/bin/aforge")

assert AIIntegrationConfig.from_env().aforge_bin == "/usr/local/bin/aforge"


def test_provider_env_forwards_provider_keys(monkeypatch: pytest.MonkeyPatch) -> None:
"""Harness subprocess env carries the LLM key AForge needs to reach OpenRouter."""
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-test-key")

env = AIIntegrationConfig.from_env().provider_env()

assert env["OPENROUTER_API_KEY"] == "sk-test-key"