Skip to content
Draft
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
25 changes: 20 additions & 5 deletions docs/v6/cookbooks/coding-agent.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ CHECKS = Path("checks").resolve() # grader-only, outside the workspace
TEST = "from calc import add\n\ndef test_add():\n assert add(2, 3) == 5\n"

env = Environment(name="coder")
env.workspace(ROOT)
env.workspace(ROOT, network=True)

@env.initialize
async def _seed():
Expand Down Expand Up @@ -60,16 +60,31 @@ Point a coding agent at the environment. `claude` opens the `ssh` capability, ed
hud eval env.py claude
```

For Claude Code (the `claude` CLI driving the shell over SSH), use the `ClaudeSDKAgent` in code:
To run the `claude` CLI over SSH, select the `claude_cli` agent:

```bash
hud eval env.py claude_cli --gateway
```

Codex uses the same environment through the `codex_cli` agent:

```bash
hud eval env.py codex_cli --gateway
```

The SSH runtime must expose the selected executable, either as a managed runtime bundle or through
the environment image. The agent validates the effective runtime OS against the live SSH target,
prefers a compatible managed bundle, and otherwise resolves the executable from `PATH`. CLI agents
do not download or update executables. The equivalent Claude Python API is:

```python run.py
import asyncio
from hud.agents import ClaudeSDKAgent
from hud.agents.types import ClaudeSDKConfig
from hud.agents import ClaudeCLIAgent
from hud.agents.types import ClaudeCLIConfig
from env import fix_add

async def main():
agent = ClaudeSDKAgent(ClaudeSDKConfig(model="claude-sonnet-4-5"))
agent = ClaudeCLIAgent(ClaudeCLIConfig(model="claude-sonnet-5"))
job = await fix_add().run(agent)
print("reward:", job.reward)

Expand Down
8 changes: 5 additions & 3 deletions docs/v6/guides/running-an-eval.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,11 @@ and launch - no CLI required.

### Choosing an agent

The agent name (`claude`, `openai`, `gemini`) selects a built-in harness and routes calls through the
[HUD gateway](/v6/reference/agents), where one `HUD_API_KEY` covers every provider. Switching models is a
single flag, and `hud models list` shows every model the gateway knows.
The agent name (`claude`, `openai`, `gemini`) selects a provider harness and routes calls through the
[HUD gateway](/v6/reference/agents), where one `HUD_API_KEY` covers every provider. The `claude_cli` and
`codex_cli` harnesses instead run an installed CLI inside the environment; pass `--gateway` to route
their model calls through HUD. Switching models is a single flag, and `hud models list` shows every
model the gateway knows.

```bash
hud eval "My Taskset" claude --model claude-haiku-4-5 # a cheaper model for fast iteration
Expand Down
20 changes: 12 additions & 8 deletions docs/v6/reference/agents.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -59,16 +59,20 @@ agent = ClaudeAgent(ClaudeConfig(model="claude-sonnet-4-5", max_steps=30))
| `OpenAIAgent` | `OpenAIConfig` | `gpt-5.6` |
| `GeminiAgent` | `GeminiConfig` | `gemini-3-pro-preview` |
| `OpenAIChatAgent` | `OpenAIChatConfig` | `gpt-5.4-mini` |
| `ClaudeSDKAgent` | `ClaudeSDKConfig` | `claude-sonnet-4-6` |
| `ClaudeCLIAgent` | `ClaudeCLIConfig` | `claude-sonnet-5` |
| `CodexCLIAgent` | `CodexCLIConfig` | `gpt-5.6-sol` |

Each config lives in `hud.agents.types`. `OpenAIChatAgent` speaks the OpenAI Chat Completions API, so it
points at any compatible server (vLLM, a local model) via `base_url`; `ClaudeSDKAgent` runs the `claude`
CLI over an `ssh` capability, against the env's filesystem. SSH-only runs support POSIX and Windows
workspaces; computer use over an `rfb` capability currently requires a POSIX workspace. Every knob
points at any compatible server (vLLM, a local model) via `base_url`. `ClaudeCLIAgent` and
`CodexCLIAgent` run their respective CLIs over an `ssh` capability against the env's filesystem and
stream the CLI's structured events into the HUD trace. They prefer a compatible managed runtime
bundle and fall back to the environment's `PATH`; they never install or update the executable.
SSH-only runs support POSIX and Windows workspaces; Claude computer use over an `rfb` capability
currently requires a POSIX workspace. Every knob
(`model`, `max_steps`, `timeout_seconds`, `tool_timeout_seconds`, `system_prompt`, `citations_enabled`, `stop_on`) lives on the
config; `__call__(run)` takes only the run.

`timeout_seconds` bounds the complete agent phase. For provider tool agents, `tool_timeout_seconds` bounds each complete SSH-backed tool call, including multi-operation editor calls. It is unset by default except on `ClaudeConfig`, where it defaults to 120 seconds. A timeout is returned to the model as a tool error so the agent can continue. `ClaudeSDKAgent` does not apply this setting because its SSH process is the complete Claude Code agent, not one tool call.
`timeout_seconds` bounds the complete agent phase. For provider tool agents, `tool_timeout_seconds` bounds each complete SSH-backed tool call, including multi-operation editor calls. It is unset by default except on `ClaudeConfig`, where it defaults to 120 seconds. A timeout is returned to the model as a tool error so the agent can continue. `ClaudeCLIAgent` does not apply this setting because its SSH process is the complete Claude Code agent, not one tool call.

```python
agent = OpenAIChatAgent(
Expand Down Expand Up @@ -100,8 +104,8 @@ A model id maps to one of four gateway agent types (`AgentType`), each a provide
| `gemini` | `GeminiAgent` |
| `openai_compatible` | `OpenAIChatAgent` |

For a provider key instead of the gateway, or for `ClaudeSDKAgent` (not a gateway type), construct the
provider agent directly.
For a provider key instead of the gateway, or for a CLI agent (not a gateway shortcut), construct the
agent directly.

## Agent

Expand All @@ -126,7 +130,7 @@ print(job.reward)
```

**From the CLI**, `hud eval` takes a task source and an agent name (`claude`, `openai`, `gemini`,
`openai_compatible`); see [running an eval](/v6/guides/running-an-eval) for the walkthrough and the
`openai_compatible`, `claude_cli`, `codex_cli`); see [running an eval](/v6/guides/running-an-eval) for the walkthrough and the
[CLI reference](/v6/reference/cli#hud-eval) for the full flag set.

## Bring your own harness
Expand Down
6 changes: 3 additions & 3 deletions docs/v6/reference/runtime.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -226,9 +226,9 @@ provisioning.

Where `HUDRuntime` runs the agent loop locally against a tunneled env, `HostedRuntime` runs the
**whole rollout** remotely, with the agent running alongside the task environment. This process
only submits the rollout and polls its trace to completion. It supports gateway agents from
[`create_agent`](/v6/reference/agents#create-agent); agents with a custom `model_client` must use
`HUDRuntime` or `LocalRuntime`.
only submits the rollout and polls its trace to completion. It supports registered built-in agents;
[`create_agent`](/v6/reference/agents#create-agent) supplies the gateway-backed ones. Agents with a
custom `model_client` must use `HUDRuntime` or `LocalRuntime`.

### `Runtime`

Expand Down
30 changes: 21 additions & 9 deletions hud/agents/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from typing import TYPE_CHECKING, Any, cast

from hud.agents.registry import dump_agent, load_agent
from hud.settings import settings
from hud.types import AgentType
from hud.utils.exceptions import HudAuthenticationError
Expand All @@ -19,7 +20,8 @@
if TYPE_CHECKING:
from typing import TypeAlias

from hud.agents.claude import ClaudeAgent, ClaudeSDKAgent, ClaudeSDKConfig
from hud.agents.claude import ClaudeAgent, ClaudeCLIAgent, ClaudeCLIConfig
from hud.agents.codex import CodexCLIAgent, CodexCLIConfig
from hud.agents.gemini import GeminiAgent
from hud.agents.openai import OpenAIAgent
from hud.agents.openai_compatible import OpenAIChatAgent
Expand Down Expand Up @@ -51,7 +53,10 @@ def create_agent(model: str, **kwargs: Any) -> GatewayAgent:

requested_model = model
model = normalize_gateway_model_id(model)
agent_type = next((candidate for candidate in AgentType if candidate.value == model), None)
agent_type = next(
(candidate for candidate in AgentType if not candidate.is_cli and candidate.value == model),
None,
)
if agent_type is not None:
model_id = model
else:
Expand Down Expand Up @@ -84,12 +89,14 @@ def create_agent(model: str, **kwargs: Any) -> GatewayAgent:
agent_type = AgentType(agent_str)
except ValueError as exc:
raise ValueError(f"Model '{model}' has invalid agent type metadata") from exc
if agent_type.is_cli:
raise ValueError(f"Model '{model}' has invalid agent type metadata")
model_id = gateway_model.model_name or model
break
else:
import difflib

known = [c.value for c in AgentType] + [
known = [c.value for c in AgentType if not c.is_cli] + [
n
for gm in gateway_models
for n in (gm.id, gm.name, gm.model_name)
Expand All @@ -110,15 +117,16 @@ def create_agent(model: str, **kwargs: Any) -> GatewayAgent:
raise ValueError(f"Model {requested_model!r} not found in {source}.{hint}")

kwargs.setdefault("model", model_id)
# cls/config_cls are matched unions; the pairing is correct by construction.
config = agent_type.config_cls(**kwargs)
return agent_type.cls(cast("Any", config))
return cast("GatewayAgent", agent_type.instantiate(config))


_LAZY_EXPORTS = {
"ClaudeAgent": ("hud.agents.claude", "ClaudeAgent"),
"ClaudeSDKAgent": ("hud.agents.claude", "ClaudeSDKAgent"),
"ClaudeSDKConfig": ("hud.agents.claude", "ClaudeSDKConfig"),
"ClaudeCLIAgent": ("hud.agents.claude", "ClaudeCLIAgent"),
"ClaudeCLIConfig": ("hud.agents.claude", "ClaudeCLIConfig"),
"CodexCLIAgent": ("hud.agents.codex", "CodexCLIAgent"),
"CodexCLIConfig": ("hud.agents.codex", "CodexCLIConfig"),
"GeminiAgent": ("hud.agents.gemini", "GeminiAgent"),
"MCPAgent": ("hud.agents.tool_agent", "ToolAgent"),
"OpenAIAgent": ("hud.agents.openai", "OpenAIAgent"),
Expand All @@ -127,13 +135,17 @@ def create_agent(model: str, **kwargs: Any) -> GatewayAgent:

__all__ = [
"ClaudeAgent",
"ClaudeSDKAgent",
"ClaudeSDKConfig",
"ClaudeCLIAgent",
"ClaudeCLIConfig",
"CodexCLIAgent",
"CodexCLIConfig",
"GeminiAgent",
"MCPAgent",
"OpenAIAgent",
"OpenAIChatAgent",
"create_agent",
"dump_agent",
"load_agent",
]


Expand Down
6 changes: 3 additions & 3 deletions hud/agents/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,13 @@


class Agent(ABC):
"""Drives a live ``Run`` to completion by filling ``run.trace`` in place.
"""Drives a live ``Run`` by recording its trajectory and final answer.

Subclasses implement ``__call__(run)``; callers do ``await agent(run)``. Stateless
per run — everything comes from ``run`` — so one instance drives many concurrent
rollouts.
rollouts. The caller owns lifecycle status, cancellation, and grading.
"""

@abstractmethod
async def __call__(self, run: Run) -> None:
"""Drive ``run`` to completion, filling ``run.trace`` (answer is ``trace.content``)."""
"""Fill ``run.trace`` with the trajectory and final answer."""
8 changes: 0 additions & 8 deletions hud/agents/browser_use/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@
from hud.agents.base import Agent
from hud.agents.types import AgentStep, BrowserUseConfig
from hud.settings import settings
from hud.types import Step

if TYPE_CHECKING:
from hud.eval.run import Run
Expand Down Expand Up @@ -107,18 +106,12 @@ async def __call__(self, run: Run) -> None:

try:
history = await sdk_agent.run(max_steps=self.config.max_steps)
except Exception as exc:
LOGGER.exception("browser-use run failed")
trace.status = "error"
run.record(Step(source="system", error=str(exc)))
return
finally:
with contextlib.suppress(Exception):
await browser.stop()

successful = history.is_successful()
content = history.final_result() or ""
trace.status = "error" if successful is False else "completed"
trace.content = content
trace.extra.update(
{
Expand All @@ -133,7 +126,6 @@ async def __call__(self, run: Run) -> None:
AgentStep(
content=content,
done=history.is_done(),
error=content if successful is False else None,
),
)

Expand Down
6 changes: 3 additions & 3 deletions hud/agents/claude/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,15 @@
AsyncAnthropicBedrock,
ClaudeAgent,
)
from .sdk import ClaudeSDKAgent, ClaudeSDKConfig
from .sdk import ClaudeCLIAgent, ClaudeCLIConfig
from .tools import ClaudeToolSearchTool, ClaudeWebFetchTool, ClaudeWebSearchTool

__all__ = [
"AsyncAnthropic",
"AsyncAnthropicBedrock",
"ClaudeAgent",
"ClaudeSDKAgent",
"ClaudeSDKConfig",
"ClaudeCLIAgent",
"ClaudeCLIConfig",
"ClaudeToolSearchTool",
"ClaudeWebFetchTool",
"ClaudeWebSearchTool",
Expand Down
34 changes: 21 additions & 13 deletions hud/agents/claude/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -257,44 +257,52 @@ async def get_response(
if response is None:
raise ValueError("Claude response missing after retries")

result = AgentStep(content="", done=True)
result.model = response.model
result.usage = Usage(
prompt_tokens=response.usage.input_tokens,
completion_tokens=response.usage.output_tokens,
cached_tokens=response.usage.cache_read_input_tokens,
return self.message_to_agent_step(response, citations_enabled=citations_enabled)

@classmethod
def message_to_agent_step(
cls,
response: BetaMessage,
*,
citations_enabled: bool = False,
) -> AgentStep:
result = AgentStep(
content="",
done=True,
model=response.model,
usage=Usage(
prompt_tokens=response.usage.input_tokens,
completion_tokens=response.usage.output_tokens,
cached_tokens=response.usage.cache_read_input_tokens,
),
)
text_parts: list[str] = []
thinking_parts: list[str] = []
citations: list[Citation] = []

for block in response.content:
match block.type:
case "tool_use":
arguments = dict(block.input) if block.input else {}
result.tool_calls.append(
MCPToolCall(
id=block.id,
name=block.name,
arguments=arguments,
arguments=dict(block.input) if block.input else {},
_meta=mcp_types.RequestParams.Meta.model_validate(
{"citations_enabled": citations_enabled},
),
)
)
result.done = False
case "text":
text_block = block
text_parts.append(text_block.text)
citations.extend(self._citation(c) for c in (text_block.citations or []))
text_parts.append(block.text)
result.citations.extend(cls._citation(c) for c in (block.citations or []))
case "thinking":
if block.thinking:
thinking_parts.append(block.thinking)
case _:
pass

result.content = "".join(text_parts)
result.citations = citations
if thinking_parts:
result.reasoning = "\n".join(thinking_parts)
result.finish_reason = response.stop_reason
Expand Down
8 changes: 5 additions & 3 deletions hud/agents/claude/sdk/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Claude Agent SDK agent."""
"""Agent that runs the ``claude`` CLI over SSH."""

from .agent import ClaudeSDKAgent, ClaudeSDKConfig
from hud.agents.types import ClaudeCLIConfig

__all__ = ["ClaudeSDKAgent", "ClaudeSDKConfig"]
from .agent import ClaudeCLIAgent

__all__ = ["ClaudeCLIAgent", "ClaudeCLIConfig"]
Loading
Loading