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
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@

<div align="center">
<a href="https://agentfield.ai/docs/build/intelligence/harness?utm_source=github-readme&utm_campaign=github-readme&utm_id=github-readme-harness-banner">
<img src="assets/harness-banner.png" alt="Now includes Harness Orchestration — multi-turn coding agents with Claude Code, Codex, Gemini CLI, and OpenCode" width="100%" />
<img src="assets/harness-banner.png" alt="Now includes Harness Orchestration — multi-turn coding agents with AForge, Claude Code, Codex, Gemini CLI, and OpenCode" width="100%" />
</a>
</div>

Expand Down Expand Up @@ -251,7 +251,7 @@ Two examples already run at this load. The [deep-research engine](https://agentf

- **[Reasoners & Skills](https://agentfield.ai/docs/build/building-blocks/reasoners?utm_source=github-readme&utm_campaign=github-readme&utm_id=github-readme-reasoners)** - `@app.reasoner()` for AI judgment, `@app.skill()` for deterministic code
- **[Structured AI](https://agentfield.ai/docs/reference/sdks/python?utm_source=github-readme&utm_campaign=github-readme&utm_id=github-readme-structured-ai)** - `app.ai(schema=MyModel)` → typed Pydantic/Zod output from any LLM
- **[Harness](https://agentfield.ai/docs/build/intelligence/harness?utm_source=github-readme&utm_campaign=github-readme&utm_id=github-readme-harness)** - `app.harness("Fix the bug")` dispatches multi-turn tasks to Claude Code, Codex, Gemini CLI, or OpenCode
- **[Harness](https://agentfield.ai/docs/build/intelligence/harness?utm_source=github-readme&utm_campaign=github-readme&utm_id=github-readme-harness)** - `app.harness("Fix the bug")` dispatches multi-turn tasks to AForge, AgentField's own coding harness — no setup. Add `provider="claude-code"` (or `codex`, `gemini`, `opencode`) to orchestrate someone else's.
- **[Cross-Agent Calls](https://agentfield.ai/docs/build/coordination/cross-agent-calls?utm_source=github-readme&utm_campaign=github-readme&utm_id=github-readme-cross-agent-calls)** - `app.call("other-agent.func")` routes through the control plane with full tracing
- **[Discovery](https://agentfield.ai/docs/reference/sdks/python?utm_source=github-readme&utm_campaign=github-readme&utm_id=github-readme-discovery)** - `app.discover(tags=["ml*"])` finds agents and capabilities across the mesh. `tools="discover"` lets LLMs auto-invoke them.
- **[Memory](https://agentfield.ai/docs/build/coordination/shared-memory?utm_source=github-readme&utm_campaign=github-readme&utm_id=github-readme-memory)** - `app.memory.set()` / `.get()` / `.similarity_search()` - KV + vector search, four scopes, no Redis needed
Expand Down Expand Up @@ -283,7 +283,8 @@ Two examples already run at this load. The [deep-research engine](https://agentf
| Feature | How |
|---|---|
| Structured output (Pydantic/Zod) | `app.ai(schema=MyModel)` |
| Multi-turn coding agents | `app.harness("task", provider="claude-code")` |
| Multi-turn coding agents | `app.harness("task")` — AForge by default |
| Orchestrate another harness | `app.harness("task", provider="claude-code")` |
| LLM auto-discovers agents and tools | `app.ai(tools="discover")` |
| Multimodal (text, image, audio) | `app.ai("Describe", image_url="...")` |
| Streaming responses | `app.ai("...", stream=True)` |
Expand Down Expand Up @@ -379,7 +380,9 @@ Two examples already run at this load. The [deep-research engine](https://agentf

| Feature | How |
|---|---|
| 4 providers | Claude Code, Codex, Gemini CLI, OpenCode |
| Zero-setup default harness | AForge (`aforge`), installed alongside `af` |
| Swap the worker, keep the loop | `provider="claude-code"` \| `"codex"` \| `"gemini"` \| `"opencode"` |
| Fleet-wide default override | `AGENTFIELD_HARNESS_PROVIDER=codex` |
| Schema-constrained output | `schema=ResultModel` (Pydantic/Zod) |
| Cost capping | `max_budget_usd=3.0` |
| Turn limiting | `max_turns=100` |
Expand Down
25 changes: 13 additions & 12 deletions docs/design/harness-v2-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ app = Agent(
node_id="my-agent",
ai_config=AIConfig(model="openai/gpt-4o"),
harness_config=HarnessConfig(
provider="claude-code", # Requiredno implicit default
model="sonnet",
provider="claude-code", # Optionaldefaults to "aforge"
model="sonnet", # Optional — defaults to the provider's own
),
)
```
Expand All @@ -38,8 +38,8 @@ import { Agent } from '@agentfield/sdk';
const agent = new Agent({
nodeId: 'my-agent',
harnessConfig: {
provider: 'claude-code', // Required
model: 'sonnet',
provider: 'claude-code', // Optional — defaults to 'aforge'
model: 'sonnet', // Optional — defaults to the provider's own
},
});
```
Expand Down Expand Up @@ -112,7 +112,7 @@ app = Agent(node_id="minimal-agent")

result = await app.harness(
"Fix the bug",
provider="gemini", # Required when no harness_config
provider="gemini", # Optional — omit to use the default, "aforge"
model="flash",
cwd="/my/project",
)
Expand Down Expand Up @@ -142,7 +142,7 @@ async def fix_issue(issue: dict) -> dict:
```
Agent
├── .ai() → AIConfig → LiteLLM → LLM APIs (100+ providers)
└── .harness() → HarnessConfig → HarnessRunner → Provider → {Claude Code, Codex, Gemini, OpenCode}
└── .harness() → HarnessConfig → HarnessRunner → Provider → {Aforge, Claude Code, Codex, Gemini, OpenCode}
```

### 3.2 Component Stack
Expand Down Expand Up @@ -350,12 +350,12 @@ Layer 4: Full retry (expensive, last resort
class HarnessConfig(BaseModel):
"""Configuration for coding agent harness calls.

Provider is required — there is no implicit default.
All other fields have sensible defaults that can be overridden per-call.
Provider defaults to "aforge", AgentField's native harness.
All fields have sensible defaults that can be overridden per-call.
"""
# Provider selection (required)
provider: str # "claude-code" | "codex" | "gemini" | "opencode"
model: str = "sonnet"
# Provider selection: explicit > AGENTFIELD_HARNESS_PROVIDER > "aforge"
provider: str = "aforge" # | "claude-code" | "codex" | "gemini" | "opencode"
model: Optional[str] = None # None → the provider's own default

# Execution limits
max_turns: int = 30
Expand Down Expand Up @@ -426,7 +426,8 @@ interface HarnessConfig {
1. HarnessConfig defaults (set at agent construction)
2. Per-call overrides (passed to .harness() method)
→ Per-call values win over HarnessConfig defaults
→ If no HarnessConfig AND no per-call provider → raise error
→ If no HarnessConfig AND no per-call provider → AGENTFIELD_HARNESS_PROVIDER,
then the default provider "aforge"
```

---
Expand Down
113 changes: 98 additions & 15 deletions docs/harness-providers.md
Original file line number Diff line number Diff line change
@@ -1,18 +1,83 @@
# Harness providers

AgentField harness providers run external coding agents. Install the provider
wrapper you need, install its CLI when required, and verify the runtime before
starting a workflow.
`app.harness()` hands a task to a coding agent — a multi-turn worker that reads,
writes, and edits files, then reports back through the same structured-output
contract as `app.ai()`. AgentField ships its own harness, **AForge**, and it is
the default: a call with no provider set runs `aforge`. Naming a different
provider swaps the worker without changing the surrounding loop, which is how
you orchestrate Claude Code, Codex, Gemini CLI, or OpenCode from a reasoner.

## Default: AForge

The `aforge` binary is provisioned alongside the `af` CLI — the curl installer,
the desktop app, and the published Docker images all ship it. To install or
repair it on demand:

```bash
af aforge ensure
```

Set `OPENROUTER_API_KEY`, then call the harness with nothing else configured:

```python
result = await app.harness("Fix the failing test in tests/test_auth.py", schema=Report)
```

```go
result, err := agent.Harness(ctx, task, schema, &dest, harness.Options{Cwd: repoRoot})
```

```ts
const result = await app.harness(task, { schema });
```

The model defaults to AForge's own default. Set `AFORGE_MODEL` to change it
process-wide, or pass `model=` per call.

Verify the runtime before a paid run:

```bash
af harness doctor --provider aforge
```

## Choosing a different provider

Provider selection follows one precedence chain:

| Order | Source | Example |
| --- | --- | --- |
| 1 | Explicit value on the call or in the agent's harness config | `app.harness(task, provider="codex")` |
| 2 | `AGENTFIELD_HARNESS_PROVIDER` environment variable | `AGENTFIELD_HARNESS_PROVIDER=claude-code` |
| 3 | Default | `aforge` |

Same loop code, different worker:

```python
# AForge — nothing to configure
report = await app.harness(task, schema=Report)

# Orchestrate Claude Code instead
report = await app.harness(task, schema=Report, provider="claude-code")

# ...or Codex, Gemini CLI, OpenCode
report = await app.harness(task, schema=Report, provider="codex")
```

The same override exists in every SDK — `harness.Options{Provider: harness.ProviderCodex}`
in Go, `{ provider: 'codex' }` in TypeScript — and an agent-wide default can be
set once on the agent's harness config (`HarnessConfig(provider="codex")` in
Python, `agent.HarnessConfig{Provider: "codex"}` in Go).

## Install

| Provider | Python extra | Required CLI | Authentication |
| --- | --- | --- | --- |
| `aforge` | None | `aforge` (`af aforge ensure`) | `OPENROUTER_API_KEY` |
| Claude Code | `agentfield[harness-claude]` | Bundled by `claude-agent-sdk` | Claude login or `ANTHROPIC_API_KEY` |
| Codex | `agentfield[harness-codex]` | `codex` | Codex login or `OPENAI_API_KEY` |
| Gemini | None | `gemini` | Gemini login, `GEMINI_API_KEY`, or `GOOGLE_API_KEY` |
| OpenCode | `agentfield[harness-opencode]` | `opencode` | Provider credentials configured in OpenCode |
| Provider | Install | Python extra | Required CLI | Authentication |
| --- | --- | --- | --- | --- |
| `aforge` (default) | `af aforge ensure` (shipped with `af`) | None | `aforge` | `OPENROUTER_API_KEY` |
| `claude-code` | `pip install 'agentfield[harness-claude]'` | `agentfield[harness-claude]` | Bundled by `claude-agent-sdk` | Claude login or `ANTHROPIC_API_KEY` |
| `codex` | `npm install -g @openai/codex` | `agentfield[harness-codex]` | `codex` | Codex login or `OPENAI_API_KEY` |
| `gemini` | `npm install -g @google/gemini-cli` | None | `gemini` | Gemini login, `GEMINI_API_KEY`, or `GOOGLE_API_KEY` |
| `opencode` | `curl -fsSL https://opencode.ai/install \| bash` | `agentfield[harness-opencode]` | `opencode` | Provider credentials configured in OpenCode |
| `grok` | Install the Grok Build CLI, then `grok login` | None | `grok` | `XAI_API_KEY` |

Install every Python wrapper with:

Expand All @@ -34,13 +99,31 @@ The pinned build, its download host and the opt-out are documented under
[docs/ENVIRONMENT_VARIABLES.md](ENVIRONMENT_VARIABLES.md).

The extras install Python wrappers. They do not replace the runtime preflight:
Aforge and Gemini are CLI-only, and Codex or OpenCode may still require a
separately available executable depending on the wrapper and platform.
AForge and Gemini are CLI-only, and Codex or OpenCode may still require a
separately available executable depending on the wrapper and platform. `grok`
is available in the Python SDK only.

### AForge adapter contract

AForge is registered as `aforge` in the Python, Go, and TypeScript SDKs. The
adapters default to the direct non-interactive contract, `aforge exec --json`,
send the task over stdin, and map AForge's usage ledger into AgentField turns,
token counts, and cost metrics. Set `AGENTFIELD_AFORGE_COMMAND=do` to opt into
the routed `aforge do --json --yes-spend` workflow instead.

Set `AFORGE_MAX_CONCURRENT` to cap simultaneous AForge subprocesses. The
default is 8. `AGENTFIELD_HARNESS_TIMEOUT_SECONDS` is the outer watchdog; each
adapter gives AForge a five-second landing window to emit its exit-2 timeout
envelope. Schema runs use a unique output directory per invocation so parallel
jobs can safely share a checkout. Set `AFORGE_BIN` to an absolute path when the
binary is installed somewhere off `PATH`.

## Model selection and reasoning-effort variants

Every provider accepts a `model` option on `.harness()` calls. The model string
may carry a reasoning-effort variant after a `#` separator:
Every provider accepts a `model` option on `.harness()` calls. Leaving it unset
uses the provider's own default — AForge picks its own model, `claude-code`
keeps using `sonnet`. The model string may carry a reasoning-effort variant
after a `#` separator:

```python
result = await app.harness(
Expand All @@ -54,7 +137,7 @@ An explicit `variant="high"` keyword wins over the suffix. Per provider:

| Provider | Model flag | Variant handling |
| --- | --- | --- |
| `aforge` | `AFORGE_MODEL` env var with a bare OpenRouter slug (a leading `openrouter/` is stripped) | `AFORGE_EXEC_REASONING` (`off`, `low`, `medium`, or `high`) |
| `aforge` | `exec`: `--model` and `--plan-model`; `do`: `AFORGE_MODEL` (a leading `openrouter/` is stripped) | `AFORGE_EXEC_REASONING` (`off`, `low`, `medium`, or `high`) |
| OpenCode | `-m <model>` | `--variant <v>` (provider-specific effort, e.g. `high`, `max`, `minimal`) |
| Codex | `-m <model>` | `-c model_reasoning_effort=<v>` |
| Claude Code | SDK `model` option | No effort control — variant is dropped with a debug log |
Expand Down
12 changes: 7 additions & 5 deletions sdk/go/agent/harness.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,14 @@ import (
// providing lazy initialization and a convenience Harness() method.
// HarnessConfig configures the default harness runner for the agent.
type HarnessConfig struct {
// Provider is the default provider: "claude-code", "codex", "gemini", or "opencode".
// Provider is the default provider: "aforge", "claude-code", "codex",
// "gemini", or "opencode". When empty, AGENTFIELD_HARNESS_PROVIDER
// overrides the default, "aforge" (AgentField's native harness). An
// explicit value always wins.
Provider string

// Model is the default model identifier. It may carry a
// Model is the default model identifier. Empty means the provider's own
// default. It may carry a
// reasoning-effort variant after a "#" separator (e.g.
// "openrouter/z-ai/glm-5.2#high").
Model string
Expand Down Expand Up @@ -86,9 +90,7 @@ func (a *Agent) HarnessRunner() *harness.Runner {
// }
// var result ReviewResult
// schema, _ := harness.StructToJSONSchema(result)
// hr, err := agent.Harness(ctx, "Review this code...", schema, &result, harness.Options{
// Model: "sonnet",
// })
// hr, err := agent.Harness(ctx, "Review this code...", schema, &result, harness.Options{})
func (a *Agent) Harness(ctx context.Context, prompt string, schema map[string]any, dest any, opts harness.Options) (*harness.Result, error) {
result, err := a.HarnessRunner().Run(ctx, prompt, schema, dest, opts)
if err == nil {
Expand Down
22 changes: 13 additions & 9 deletions sdk/go/agent/harness_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"io"
"log"
"path/filepath"
"testing"

"github.com/Agent-Field/agentfield/sdk/go/harness"
Expand Down Expand Up @@ -112,28 +113,31 @@ func TestHarnessRunner_ConcurrentAccess(t *testing.T) {
}

func TestHarness_ErrorWithoutProvider(t *testing.T) {
// Harness() should fail when no provider is configured.
// The runner will return an error about a missing provider.
// With no provider configured, Harness() defaults to aforge and reaches
// provider execution. A missing binary may fail, but provider resolution does not.
t.Setenv(harness.ProviderEnvVar, "")
a := newTestAgentForHarness(t)

_, err := a.Harness(context.Background(), "do something", nil, nil, harness.Options{})
assert.Error(t, err)
assert.Contains(t, err.Error(), "provider")
missingAforge := filepath.Join(t.TempDir(), "missing-aforge")
result, err := a.Harness(context.Background(), "do something", nil, nil, harness.Options{BinPath: missingAforge})
require.NoError(t, err)
require.NotNil(t, result)
assert.True(t, result.IsError)
assert.Contains(t, result.ErrorMessage, "missing-aforge")
assert.NotContains(t, result.ErrorMessage, "no harness provider specified")
}

func TestHarness_PassesOptsToRunner(t *testing.T) {
// Verify that per-call Options are forwarded to the runner.
// Using a non-existent provider triggers a provider-build error,
// which confirms the Options reached Run() (otherwise we'd get
// the "no harness provider specified" error instead).
// which confirms the Options reached Run().
a := newTestAgentForHarness(t)

_, err := a.Harness(context.Background(), "test", nil, nil, harness.Options{
Provider: "nonexistent-provider",
})
assert.Error(t, err)
// Should be a provider-build error, NOT the "no harness provider specified" error
assert.NotContains(t, err.Error(), "no harness provider specified")
assert.Contains(t, err.Error(), "unknown harness provider")
}

func TestHarnessConfig_PartialOverride(t *testing.T) {
Expand Down
Loading
Loading