Repository navigation
Add opt-in Claude agent backend (#28) - #58
Conversation
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", |
There was a problem hiding this 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.
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.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>
| if (overrides.agent !== undefined && overrides.agent !== profile.agent && overrides.model === undefined) { | ||
| delete profile.model; | ||
| delete profile.reasoning; | ||
| } |
There was a problem hiding this 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.
| 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.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>
Closes #28.
Summary
--agentfrom carrying the previous agent's model onto the new oneValidation
smithers-orchestrator0.27.0 and driven end-to-end under bun, since templates are emitted as text and the workspace build does not cover themWhat this does
ultrafuzz initnow also generates aClaudeAgentbacked by the Claude Code CLI (claude --print). The default profile stays onCodexAgent; Claude is opt-in via the newclaudeprofile, selected per node withmodel_profiles = ["claude"]or for a single run with--agent ClaudeAgent.Both
authmodes drive theclaudeCLI —api-keyonly 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:
--agent ClaudeAgentset the agent but kept the default profile's model, so Claude launched with Codex'sgpt-5.5. Switching agents without naming a model now drops the previous agent's model and reasoning;--modelstill pins it. The override logic was duplicated verbatim invalidate.tsandplan-run.tsand now lives once in@ultrafuzz/config.OPENAI_API_KEY is not set. Agents are now constructed per task.initpreserves project-owned files, so an existing project gotclaude.tson disk with nothing exporting it.initnow 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
ClaudeAgentbackend 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 afterinit.ultrafuzz initnow generates.smithers/agents/claude.ts(viaclaude.tsxtemplate) alongside a shared TOML helper (toml.tsx) used by both agent adapters. The default profile stays onCodexAgent; Claude is opt-in viamodel_profiles = ["claude"]or--agent ClaudeAgent.applyDefaultProfileOverridesis extracted into@ultrafuzz/configand replaces duplicated inline code in bothvalidate.tsandplan-run.ts; switching agents without--modelnow clears the previous agent'smodelandreasoningbefore applying the new agent.agents/index.tsno longer exports a top-levelCodexAgentinstance; both agents are now exposed solely as factory functions inagentFactories, 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
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]%%{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]Reviews (3): Last reviewed commit: "Document that ClaudeAgent bypasses Claud..." | Re-trigger Greptile