Skip to content

Add opt-in Claude agent backend (#28) - #58

Merged
aviggiano merged 7 commits into
mainfrom
anthropic_support
Jul 17, 2026
Merged

aviggiano merged 7 commits into
mainfrom
anthropic_support

Conversation

@mrthankyou

@mrthankyou mrthankyou commented Jul 17, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #28.

Summary

  • Add an opt-in Claude agent backend, driven by the Claude Code CLI
  • Construct project agents per task instead of at registry import
  • Qualify the generated agents import so a sibling file cannot shadow it
  • Keep --agent from carrying the previous agent's model onto the new one
  • Report a stale agent registry instead of failing later at launch

Validation

  • Runtime test suite — 59 passed, 1 skipped, 0 failed
  • Config test suite — 16 passed
  • CLI test suite — 6 passed
  • Full workspace build and typecheck — clean
  • Generated adapters typechecked against smithers-orchestrator 0.27.0 and driven end-to-end under bun, since templates are emitted as text and the workspace build does not cover them

What this does

ultrafuzz init now also generates a ClaudeAgent backed by the Claude Code CLI (claude --print). The default profile stays on CodexAgent; Claude is opt-in via the new claude profile, selected per node with model_profiles = ["claude"] or for a single run with --agent ClaudeAgent.

Both auth modes drive the claude CLI — api-key only changes how that CLI authenticates, so the CLI is required either way. This is not a direct Anthropic API client.

Bugs found while reviewing the change

Each was reachable only once a second agent existed, and each has a regression test that fails without its fix:

  1. --agent ClaudeAgent set the agent but kept the default profile's model, so Claude launched with Codex's gpt-5.5. Switching agents without naming a model now drops the previous agent's model and reasoning; --model still pins it. The override logic was duplicated verbatim in validate.ts and plan-run.ts and now lives once in @ultrafuzz/config.
  2. Importing the agent registry constructed every agent, and constructing reads auth — so a Claude-only project still failed with OPENAI_API_KEY is not set. Agents are now constructed per task.
  3. init preserves project-owned files, so an existing project got claude.ts on disk with nothing exporting it. init now warns and names the missing agent rather than deferring the failure to launch.

🤖 Generated with Claude Code

Greptile Summary

This PR adds an opt-in ClaudeAgent backend driven by the Claude Code CLI, and proactively fixes three bugs exposed by having two agents: model/reasoning carryover when switching agents via --agent, agent auth being eagerly read at registry import time, and stale agent registry detection after init.

  • New Claude backend: ultrafuzz init now generates .smithers/agents/claude.ts (via claude.tsx template) alongside a shared TOML helper (toml.tsx) used by both agent adapters. The default profile stays on CodexAgent; Claude is opt-in via model_profiles = ["claude"] or --agent ClaudeAgent.
  • Centralized override logic: applyDefaultProfileOverrides is extracted into @ultrafuzz/config and replaces duplicated inline code in both validate.ts and plan-run.ts; switching agents without --model now clears the previous agent's model and reasoning before applying the new agent.
  • Lazy agent construction: The generated agents/index.ts no longer exports a top-level CodexAgent instance; both agents are now exposed solely as factory functions in agentFactories, so importing the registry cannot trigger auth reads for backends the project doesn't use.

Confidence Score: 5/5

Safe to merge; the three proactively fixed bugs each have a targeted regression test, the shared TOML decoder correctly handles all TOML escape sequences, and lazy agent construction prevents cross-backend credential reads.

All changes are well-tested, the new ClaudeAgent template mirrors the existing CodexAgent pattern, and the centralised override logic is correct for the primary --agent-only case. The one edge case where reasoning is not cleared when both --agent and --model are supplied together was already flagged in a prior review thread.

packages/config/src/model-profiles.ts — the clearing condition in applyDefaultProfileOverrides has a known gap noted in a previous review thread.

Important Files Changed

Filename Overview
packages/config/src/model-profiles.ts Adds applyDefaultProfileOverrides; correctly clears model/reasoning on agent switch when no model is pinned, but reasoning is not cleared when both --agent and --model are supplied together (already flagged in previous thread)
packages/runtime/src/templates/smithers/agents/toml.tsx New shared TOML basic-string decoder; correctly handles all TOML escapes including \uXXXX and \UXXXXXXXX, validates Unicode scalar range, and replaces the former JSON.parse approach
packages/runtime/src/templates/smithers/agents/claude.tsx New ClaudeAgent template; reads auth from ultrafuzz.toml, uses shared toml helper, hardcodes bypassPermissions (documented), and defers construction to call time (no top-level instance)
packages/runtime/src/templates/smithers/agents/codex.tsx Removes top-level CodexAgent instance and inline TOML helpers, now importing from the shared ./toml module; eliminates eager auth read at import time
packages/runtime/src/templates/smithers/agents/index.tsx Updated registry template exposes both agents as factory references only; no agent is constructed at import time, and agentFactories includes both ClaudeAgent and CodexAgent
packages/runtime/src/init.ts Generates toml.ts and both agent adapters during init; adds staleAgentRegistryDiagnostics to warn when a preserved registry is missing a newly generated agent factory entry
packages/runtime/src/validate.ts Replaces duplicated applyAgentOverrides inline logic with a call to the shared applyDefaultProfileOverrides from @ultrafuzz/config
packages/runtime/src/plan-run.ts Replaces duplicated applyWorkflowRunOverrides inline logic with applyDefaultProfileOverrides; no functional change beyond the agent-switch clearing behavior
packages/runtime/src/templates/smithers/workflows/workflow.tsx Changes agents import to explicit ../agents/index.ts path to prevent a sibling agents.ts scaffolded by Smithers from shadowing the agents/ directory under bun
packages/runtime/test/runtime.test.ts Adds regression tests for all three bugs fixed (model carryover, eager auth read, stale registry), and validates ClaudeAgent template structure and TOML helper sharing
docs/config.md Documents the Claude agent backend, auth modes, reasoning-effort limitation, and the fixed bypassPermissions permission mode
ultrafuzz.toml Adds [models.claude] opt-in profile and [agents.ClaudeAgent] section with subscription auth; well-commented with references to the bypassPermissions behavior

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[ultrafuzz run --agent ClaudeAgent] --> B[applyDefaultProfileOverrides]
    B --> C{agent changed AND no --model?}
    C -- yes --> D[delete profile.model and profile.reasoning]
    C -- no --> E[keep existing model/reasoning]
    D --> F[profile.agent = ClaudeAgent]
    E --> F
    F --> G[task compiled with agentRef = ClaudeAgent, modelName = null]
    H[import agents/index.ts] --> I{agent factory lookup}
    I -- ClaudeAgent --> J[createClaudeAgent called per task]
    I -- CodexAgent --> K[createCodexAgent called per task]
    J --> L[readClaudeAuthConfig from ultrafuzz.toml via toml.ts]
    K --> M[readCodexAuthConfig from ultrafuzz.toml via toml.ts]
    L --> N[new SmithersClaudeCodeAgent permissionMode bypassPermissions]
    M --> O[new SmithersCodexAgent]
    P[ultrafuzz init] --> Q[write toml.ts]
    P --> R[write claude.ts]
    P --> S[write codex.ts]
    P --> T[staleAgentRegistryDiagnostics]
    T --> U{registry exists AND agent file exists AND NOT in agentFactories?}
    U -- yes --> V[warn INIT_AGENT_REGISTRY_STALE]
    U -- no --> W[silent]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[ultrafuzz run --agent ClaudeAgent] --> B[applyDefaultProfileOverrides]
    B --> C{agent changed AND no --model?}
    C -- yes --> D[delete profile.model and profile.reasoning]
    C -- no --> E[keep existing model/reasoning]
    D --> F[profile.agent = ClaudeAgent]
    E --> F
    F --> G[task compiled with agentRef = ClaudeAgent, modelName = null]
    H[import agents/index.ts] --> I{agent factory lookup}
    I -- ClaudeAgent --> J[createClaudeAgent called per task]
    I -- CodexAgent --> K[createCodexAgent called per task]
    J --> L[readClaudeAuthConfig from ultrafuzz.toml via toml.ts]
    K --> M[readCodexAuthConfig from ultrafuzz.toml via toml.ts]
    L --> N[new SmithersClaudeCodeAgent permissionMode bypassPermissions]
    M --> O[new SmithersCodexAgent]
    P[ultrafuzz init] --> Q[write toml.ts]
    P --> R[write claude.ts]
    P --> S[write codex.ts]
    P --> T[staleAgentRegistryDiagnostics]
    T --> U{registry exists AND agent file exists AND NOT in agentFactories?}
    U -- yes --> V[warn INIT_AGENT_REGISTRY_STALE]
    U -- no --> W[silent]
Loading

Reviews (3): Last reviewed commit: "Document that ClaudeAgent bypasses Claud..." | Re-trigger Greptile

jason and others added 4 commits July 16, 2026 18:11
Generated workflows imported the project agent registry via the bare
specifier "../agents". Smithers' own project scaffolding can create
.smithers/agents.ts (a generic example file) alongside the
.smithers/agents/ directory ultrafuzz owns; bun's module resolution
prefers the sibling file over the same-named directory, so the workflow
silently loaded Smithers' unrelated demo agents instead of the generated
registry. agentRegistry[task.agentRef] then resolved to undefined, which
made Smithers treat the Task as a static no-op node whose "output" was
its own prompt text -- surfacing as an opaque "returned plain text"
schema validation error with no indication an agent never ran.

Importing "../agents/index.ts" directly is unambiguous and cannot be
shadowed by a sibling file, regardless of what Smithers' bootstrap
scaffolds alongside it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The generated registry eagerly constructed its agent at module scope
(`export const CodexAgent = createCodexAgent()`). Constructing an agent
reads its auth from ultrafuzz.toml, so importing the registry required
every configured agent's credentials, not just the ones a run actually
uses. With CodexAgent shipping `auth = "api-key"` by default, any project
that did not bill through an OpenAI key still failed at import with
"agents.CodexAgent auth is api-key, but OPENAI_API_KEY is not set",
forcing an unrelated placeholder key just to launch. Adding a second
backend would multiply that: every project would need every backend's
credentials.

Generated workflows resolve each task's agent through agentFactories, so
the eager singleton had no remaining consumer. Dropping it makes
construction lazy: a missing credential now surfaces only when that agent
is actually selected, and still surfaces as the same explicit error rather
than an undefined registry entry.

validate.ts matches agent references against the registry text, and the
factory map still names the agent, so launch-posture validation is
unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Generates a ClaudeAgent alongside CodexAgent, backed by the Claude Code
CLI, so a project can run agent tasks against a Claude subscription or the
Anthropic API instead of Codex. The default model profile still selects
CodexAgent; Claude is opt-in through the new `claude` profile, selectable
per node in topology.yml or with `ultrafuzz run --model claude`.

The adapter follows the model-profile contract from #47: model selection
arrives as a factory argument from the resolved profile rather than being
read from `agents.ClaudeAgent`, so profile and CLI overrides reach the
spawned command. Auth stays in TOML (subscription or api-key), mirroring
CodexAgent.

Both auth modes drive the `claude` CLI; api-key changes only how that CLI
authenticates, so the CLI must be installed either way. ClaudeCodeAgent
exposes no reasoning-effort option, so the factory accepts reasoningEffort
for contract parity and ignores it, and a `reasoning` value on a Claude
profile has no effect.

Agent templates are emitted as text and are not covered by the workspace
typecheck, so the generated adapters were verified against
smithers-orchestrator 0.27.0 types out of band; that check rejects
Codex-only options such as skipGitRepoCheck on ClaudeCodeAgent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three defects found reviewing the Claude backend, all reachable only once a
second agent exists:

`--agent <ref>` set the default profile's agent but left its model, so
`ultrafuzz run --agent ClaudeAgent` launched Claude with CodexAgent's
`gpt-5.5` and `xhigh` reasoning. A profile's model and reasoning are chosen
for its agent, so switching agents without naming a model now drops both and
lets the agent's own default apply; an explicit --model still pins it. The
override logic was duplicated verbatim in validate.ts and plan-run.ts and now
lives once in @ultrafuzz/config alongside the other model-profile rules.

The docs and shipped config told users to select Claude with
`ultrafuzz run --model claude`. `--model` overrides the default profile's
model *name*, it does not select a profile by id, so that command ran
CodexAgent against a model called "claude". Corrected to `--agent
ClaudeAgent`, with topology `model_profiles` as the per-node selector.

init preserves project-owned files, so a project scaffolded before this
change kept its old registry: claude.ts was written but nothing exported it,
and the agent was only rejected later, at launch. init now reports a stale
registry as a warning naming the missing agent, rather than leaving the
mismatch silent. Regenerating with --force clears it.

Also drops a docs claim that subscription auth makes cost accounting
unavailable: nothing ties accounting to auth mode, and model-pricing resolves
claude-* through the anthropic provider regardless.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
export function createClaudeAgent(options: ClaudeTaskOptions = {}): SmithersClaudeCodeAgent {
return new SmithersClaudeCodeAgent({
...(options.model === undefined ? {} : { model: options.model }),
permissionMode: "bypassPermissions",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 bypassPermissions is hardcoded with no user-visible documentation

permissionMode: "bypassPermissions" is always set unconditionally, meaning the Claude Code CLI runs with unrestricted access to the filesystem and shell for every task. The template comment above ClaudeTaskOptions notes that reasoningEffort is silently ignored, but there is no parallel note explaining that permission mode is fixed and not user-configurable. A user inspecting only the ultrafuzz.toml [agents.ClaudeAgent] section (which exposes only auth, api_key_env, and config_dir) has no indication that all permission checks are bypassed.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/runtime/src/templates/smithers/agents/claude.tsx
Line: 14

Comment:
**`bypassPermissions` is hardcoded with no user-visible documentation**

`permissionMode: "bypassPermissions"` is always set unconditionally, meaning the Claude Code CLI runs with unrestricted access to the filesystem and shell for every task. The template comment above `ClaudeTaskOptions` notes that `reasoningEffort` is silently ignored, but there is no parallel note explaining that permission mode is fixed and not user-configurable. A user inspecting only the `ultrafuzz.toml` [agents.ClaudeAgent] section (which exposes only `auth`, `api_key_env`, and `config_dir`) has no indication that all permission checks are bypassed.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code

The staleness check matched the agent name anywhere in the registry text,
which a real project defeated: a registry left over from an earlier adapter
carried `export { ClaudeAgent } from "./claude"` while agentFactories held
only CodexAgent. The name matched, init stayed quiet, and the agent was still
unselectable -- the exact case the warning exists to catch.

Generated adapters export only their factory, so agentFactories is the sole
path that resolves them; naming the agent elsewhere does not make it
selectable. The check now matches the entry inside the agentFactories object
literal instead of the bare name.

validate.ts keeps its looser name match on purpose: a hand-registered agent
exported as an instance resolves through the registry fallback, so any
mention there really can satisfy an agent reference.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment on lines +107 to +110
if (overrides.agent !== undefined && overrides.agent !== profile.agent && overrides.model === undefined) {
delete profile.model;
delete profile.reasoning;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Stale reasoning leaks when both --agent and --model are provided together

The clearing block is only triggered when overrides.model === undefined. When a user runs --agent ClaudeAgent --model claude-sonnet-5, the condition evaluates to false (because overrides.model !== undefined), so profile.reasoning is not deleted. The resulting profile keeps the old agent's reasoning value (e.g. "xhigh" from the Codex default), which flows through to task metadata as reasoningEffort: "xhigh" on a ClaudeAgent task — contradicting the invariant the PR establishes. The test for the pinned case (--agent ClaudeAgent --model claude-sonnet-5) does not include reasoningEffort in its type assertion, so this case is currently untested. The fix is to drop the && overrides.model === undefined guard so that reasoning is always cleared on any agent switch, regardless of whether a model is also supplied.

Suggested change
if (overrides.agent !== undefined && overrides.agent !== profile.agent && overrides.model === undefined) {
delete profile.model;
delete profile.reasoning;
}
// A profile's model and reasoning are chosen for its agent, so switching the
// agent must not hand one backend's model or reasoning to another.
if (overrides.agent !== undefined && overrides.agent !== profile.agent) {
delete profile.model;
delete profile.reasoning;
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/config/src/model-profiles.ts
Line: 107-110

Comment:
**Stale `reasoning` leaks when both `--agent` and `--model` are provided together**

The clearing block is only triggered when `overrides.model === undefined`. When a user runs `--agent ClaudeAgent --model claude-sonnet-5`, the condition evaluates to false (because `overrides.model !== undefined`), so `profile.reasoning` is not deleted. The resulting profile keeps the old agent's reasoning value (e.g. `"xhigh"` from the Codex default), which flows through to task metadata as `reasoningEffort: "xhigh"` on a ClaudeAgent task — contradicting the invariant the PR establishes. The test for the pinned case (`--agent ClaudeAgent --model claude-sonnet-5`) does not include `reasoningEffort` in its type assertion, so this case is currently untested. The fix is to drop the `&& overrides.model === undefined` guard so that `reasoning` is always cleared on any agent switch, regardless of whether a model is also supplied.

```suggestion
  // A profile's model and reasoning are chosen for its agent, so switching the
  // agent must not hand one backend's model or reasoning to another.
  if (overrides.agent !== undefined && overrides.agent !== profile.agent) {
    delete profile.model;
    delete profile.reasoning;
  }
```

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code

thankyou and others added 2 commits July 16, 2026 22:41
The generated adapters unescaped ultrafuzz.toml values by handing them to
JSON's string parser. A TOML basic string is not a JSON string: TOML defines
\UXXXXXXXX, which JSON rejects. `config_dir = "vault-\U0001F600"` therefore
threw an uncaught SyntaxError naming a character offset in an anonymous
string. It surfaced while the workflow was rendering its agents, so it took
down the whole run rather than reporting a bad config value.

Values are now decoded by a TOML-aware parser that handles both \uXXXX and
\UXXXXXXXX and rejects malformed, unsupported, and out-of-scalar-range
escapes with an error naming the offending key, e.g.
"agents.ClaudeAgent.config_dir has an unsupported escape \q".

The parser is emitted once as .smithers/agents/toml.ts and imported by both
adapters. Copying a corrected decoder into each would have doubled the
duplication the fix exists to remove, and a third backend would triple it.
Adapter-specific helpers stay local, since their messages name their agent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
permissionMode was pinned to "bypassPermissions" with nothing saying so
outside the generated adapter. [agents.ClaudeAgent] exposes only auth,
api_key_env, and config_dir, so a reader of ultrafuzz.toml had no way to know
every task runs with unattended filesystem and shell access.

Records it where a reader will actually look: in the config table, in
docs/config.md, and beside the option itself. The behaviour is unchanged and
still deliberate -- agents run unattended in a throwaway worktree with nobody
to answer a prompt, permissions.trust_model = "skip-permissions" is the only
trust model ultrafuzz accepts, and CodexAgent already runs the same way.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@aviggiano
aviggiano merged commit 34f5749 into main Jul 17, 2026
9 checks passed
@aviggiano
aviggiano deleted the anthropic_support branch July 17, 2026 11:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support Anthropic/Claude usage

2 participants