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
5 changes: 5 additions & 0 deletions .changeset/sdk-executor-by-default.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"uptide": minor
---

`npx uptide fix` gets the Agent SDK executor. The package stays as small as before: `@anthropic-ai/claude-agent-sdk` is not installed with Uptide. An SDK installed next to Uptide is used as it is; otherwise the first `fix` that needs it installs the version Uptide is tested with into Uptide's cache (`~/.cache/uptide/agent-sdk/<version>`), says so in one line with its size, and later runs reuse it. When that install fails (offline, a proxy) or the SDK cannot run, `fix` runs the patch executor and says so on the first line of its output and at the top of the PR body, with the reason and how to get the SDK executor. `report.json` records `llm.executor` and, after a fallback, the reason in `llm.sdkMissing`. The privacy notice names the SDK only when the SDK will run, and the LLM line names the executor. `--agent sdk` without a usable SDK stops before any work instead of failing mid-run.
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -538,7 +538,7 @@ block with the exact commands for the repository. `--details` keeps the full lis
With `--agent patch` or a non-Anthropic provider, sites a rule leaves manual go to
the patch agent (`src/fix/assisted.ts`):
one site at a time, a snippet and a guide in, one unified diff out, applied and judged per
attempt by exact diagnostic position. Authenticated Anthropic runs default to `--agent sdk` when the SDK (an optional peer) is installed, replacing
attempt by exact diagnostic position. Authenticated Anthropic runs default to `--agent sdk` when the SDK can load (next to Uptide, or fetched once into its cache), replacing
that step with `assistWithSdk` (`src/fix/sdk-agent.ts`). It runs one Claude Agent SDK session
for the package's manual sites and their rule knowledge, in the clone, with read and edit tools, Uptide's own checks
as in-process tools and no shell. One gate runs at the end of each task, matching diagnostics
Expand Down
21 changes: 14 additions & 7 deletions docs/commands/fix.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,15 +69,22 @@ Declarations outside the repository or in `node_modules` are never editable.
## Assisted-fix executor

With `ANTHROPIC_API_KEY` set and Anthropic selected, assisted fixes use the Claude Agent SDK
by default, when `@anthropic-ai/claude-agent-sdk` is installed next to Uptide. It is an
optional peer, not a dependency: it brings the platform-specific Claude Code binary (about
270 MB), which `--no-llm`, OpenAI and Gemini runs do not need. Without it, `fix` uses the
patch executor and says so in one line of its output. With `npx`:
`npx -p uptide -p @anthropic-ai/claude-agent-sdk uptide fix <package>`. The Uptide GitHub
Action installs it.
by default, `npx uptide fix <package>` included. `@anthropic-ai/claude-agent-sdk` is not
installed with Uptide: it brings the Claude Code binary for your platform (about 310 MB), which
`check`, `list`, `--no-llm`, OpenAI and Gemini runs never need. An SDK installed next to Uptide
is used as it is (the Uptide GitHub Action installs one). Otherwise the first `fix` that needs
it installs the version Uptide is tested with into Uptide's cache
(`~/.cache/uptide/agent-sdk/<version>`, or under `UPTIDE_CACHE_DIR` / `XDG_CACHE_HOME`), says so
in one line with its size, and later runs reuse it.

If that install fails (offline, a proxy) or the SDK cannot run, `fix` uses the patch executor
and says so first: as the first line of its output, and at the top of the PR body, with the
reason and how to get the SDK executor. `report.json` records the executor in `llm.executor`
and the reason in `llm.sdkMissing`.

Use `--agent patch` for the snippet-and-diff executor. OpenAI and Gemini keep that executor;
`--agent sdk` requires Anthropic. The SDK is never bundled with Uptide.
`--agent sdk` requires Anthropic and stops before any work when the SDK cannot load. The SDK
is installed from npm, never bundled with Uptide.
Without a key, rule-based packs still work; `--no-llm` disables both executors.
SDK sessions can read files throughout the temporary clone and send their contents and check
output to Anthropic; see [privacy](../privacy.md#agent-sdk-executor).
Expand Down
35 changes: 32 additions & 3 deletions docs/decisions/0016-agent-sdk-executor.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
Status: default for authenticated Anthropic fixes. `--agent patch` opts out; other providers
and callers supplying a fixer retain the patch executor. Without a key, rule-based fixes
remain available. `--no-llm` disables either executor. The SDK is an optional peer, never
bundled: without it installed, the default is the patch executor.
bundled: without one next to Uptide, the first fix installs it into Uptide's cache, and when it
cannot run, the default is the patch executor, said first in the output and the PR body.

## Why

Expand Down Expand Up @@ -101,13 +102,41 @@ unchanged.
throws", which forbid failing loudly on a missing period.
- **Final verdict.** Unchanged, and the same for both agents: diagnostics against the
pre-upgrade baseline through `gitLineMoves`.
- **Report.** `report.json` has `llm.agent: 'sdk'` and, per task, `llm.tasks[]`: turns,
- **Report.** `report.json` has `llm.executor` (`sdk` or `patch`), `llm.sdkMissing` when the
default fell back, `llm.agent: 'sdk'` and, per task, `llm.tasks[]`: turns,
tokens, cost, wall time, tool calls by name, discarded files, gate verdict, rejection, and
the summary.

## Dependency and terms

### Optional peer, the default only where installed (2026-10-12)
### Fetched on the first fix, into Uptide's cache (2026-10-18)

**Decision:** the SDK stays out of the `uptide` package (still an optional peer, still
external to both builds). An SDK resolvable next to Uptide wins, so the Action's install path
is unchanged. Otherwise the first `fix` that would use it installs the pinned `SDK_VERSION`
(the version the tests run) with `npm install` into `<cache>/agent-sdk/<version>`, says so in
one line with its size, and loads it from there; later runs reuse it. When that install fails
(offline, a proxy) or the SDK cannot run, the default runs the patch executor and says so as
the first line of the output and at the top of the PR body, with the reason and the remedy;
`report.json` has `llm.executor` and `llm.sdkMissing`. `--agent sdk` without a usable SDK
stops before any clone. This supersedes the decision below.

- **Why.** The UpgradeBench pilot (#169, round 3) ran `npx uptide@0.15.1 fix` as the README
says: nothing installed the peer, so every run used the patch executor while the notice and
docs described the SDK, and no output said so. A default nobody gets is not a default.
- **Why not a dependency.** Tried first (UPT-108): as an optional dependency, a plain
`npm install` of the packed tarball grows from 13.6 MB and 3 packages to 323.6 MB and 110
packages (node:22, linux arm64: binary 250 MB, SDK 5.5 MB, its peers 36 MB). Every `npx
uptide check` and every consumer of `uptide/onboarding` or `uptide/report` would pay that for
an executor only `fix` uses. Fetched on demand, they pay nothing; the first fix pays once.
- **Install.** `npm install --prefix <temp> --no-save --include=optional`, with lifecycle
scripts off and secrets stripped from the environment like every other install Uptide runs,
then a rename into place, so the cache holds a complete install or none.
- **Terms.** Unchanged from below. Question 2 there ("Is an npm dependency 'preinstalling'
Claude Code in a product?") becomes: is installing the published package on first use?
Still open.

### Optional peer, the default only where installed (2026-10-12, superseded)

**Decision:** the SDK stays an optional peer of `uptide`, never a dependency. The default
uses it when it can be imported; otherwise `fix` runs the patch executor and says, in one
Expand Down
8 changes: 5 additions & 3 deletions docs/privacy.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,11 @@ provider you chose (or your `OPENAI_BASE_URL`); your provider's data policy appl
## Agent SDK executor

Anthropic assisted fixes use the Claude Agent SDK by default (`--agent sdk`) when
`@anthropic-ai/claude-agent-sdk` is installed next to Uptide, which runs Anthropic's Claude
Code binary on your machine ([decision record](decisions/0016-agent-sdk-executor.md)). It is
an optional peer: Uptide neither bundles nor modifies it, and without it the patch agent runs. Its use is governed by
`@anthropic-ai/claude-agent-sdk` can load: installed next to Uptide, or installed by the first
`fix` that needs it into Uptide's cache. It runs Anthropic's Claude Code binary on your machine
([decision record](decisions/0016-agent-sdk-executor.md)). npm installs Anthropic's unmodified
package: Uptide neither bundles nor modifies it, and without it the patch agent runs and `fix`
says so. That install contacts only your npm registry. Its use is governed by
[Anthropic's terms](https://code.claude.com/docs/en/legal-and-compliance), not Uptide's
license.

Expand Down
24 changes: 17 additions & 7 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import {
ReportedError,
registeredPacks,
scaffoldPack,
sdkFallbackNote,
sdkUnavailable,
selectLlm,
TIER_LEGEND,
testPack,
Expand Down Expand Up @@ -1052,6 +1054,19 @@ ${runnable(PRIVACY)}`,
});
// `--only zod --target stripe@23.0.0` would upgrade zod to 23.0.0, or fail late.
if (target) parseTargets([target], [only]);
// The Anthropic default is the SDK executor: fetched once into the cache if needed,
// and when it cannot run, that comes first.
const sdkMissing =
llmOn &&
!flags.pinCurrentApi &&
selection.available &&
selection.provider === 'anthropic' &&
agent !== 'patch'
? await sdkUnavailable((text) => io.err(text))
: undefined;
if (sdkMissing && agent === undefined)
io.err(`warning: ${sdkFallbackNote(sdkMissing)}\n`);
const sdk = selection.provider === 'anthropic' && agent !== 'patch' && !sdkMissing;
if (flags.pr && !flags.yes)
io.err('--pr without --yes: the publication plan is printed, nothing is pushed.\n');
const repo = await progress.phase(
Expand Down Expand Up @@ -1112,14 +1127,9 @@ ${runnable(PRIVACY)}`,
);
else if (selection.available) {
io.err(
`LLM: ${selection.provider}${selection.provider === 'gemini' ? ' (experimental)' : ''} / ${selection.model} · budget $${maxCost.toFixed(2)}\n`,
);
io.err(
assistedNote(
selection.provider,
selection.provider === 'anthropic' && agent !== 'patch',
),
`LLM: ${selection.provider}${selection.provider === 'gemini' ? ' (experimental)' : ''} / ${selection.model} · executor ${sdk ? 'sdk' : 'patch'} · budget $${maxCost.toFixed(2)}\n`,
);
io.err(assistedNote(selection.provider, sdk));
const price = priceFor(selection.provider, selection.model);
if (price.fallback)
io.err(
Expand Down
9 changes: 7 additions & 2 deletions packages/cli/src/package.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { SDK_VERSION } from '@uptide/core';
import { describe, expect, it } from 'vitest';

const root = fileURLToPath(new URL('..', import.meta.url));
Expand All @@ -26,8 +27,12 @@ describe('published package', () => {
// CommonJS build calls require("process"), which breaks consumers' ES module builds.
expect(Object.keys(manifest.dependencies)).toEqual(['yaml']);
expect(manifest.devDependencies?.yaml).toBeUndefined();
// The experimental `--agent sdk` loads the Claude Agent SDK only if the user installed it.
expect(manifest.peerDependencies).toEqual({ '@anthropic-ai/claude-agent-sdk': '>=0.3.296' });
// Never installed with uptide: an SDK next to it wins, else the first fix that needs it
// installs SDK_VERSION, the version the tests run, into Uptide's cache.
expect(manifest.peerDependencies).toEqual({
'@anthropic-ai/claude-agent-sdk': `>=${SDK_VERSION}`,
});
expect(manifest.devDependencies['@anthropic-ai/claude-agent-sdk']).toBe(SDK_VERSION);
expect(manifest.peerDependenciesMeta).toEqual({
'@anthropic-ai/claude-agent-sdk': { optional: true },
});
Expand Down
109 changes: 76 additions & 33 deletions packages/core/src/fix/fix.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { published, version } from '../version.js';
import { buildWorkspaces } from './build.js';
import { git } from './process.js';
import { publicationBlockers } from './publish.js';
import { formatFix, prBody, summaryCells } from './report.js';
import { formatFix, prBody, renderMigration, summaryCells } from './report.js';
import { reverify } from './reverify.js';
import { type FixOptions, fix } from './run.js';
import * as sdkAgent from './sdk-agent.js';
Expand Down Expand Up @@ -106,24 +106,46 @@ describe('fix transaction', () => {
key: string;
sdk: boolean;
installed?: boolean;
executor?: 'sdk' | 'patch';
refused?: boolean;
}>([
{ name: 'Anthropic defaults to SDK', options: {}, key: 'test-key', sdk: true },
{
name: 'Anthropic defaults to SDK',
options: {},
key: 'test-key',
sdk: true,
executor: 'sdk',
},
{
name: 'default without the SDK installed falls back to patch',
options: {},
key: 'test-key',
sdk: false,
installed: false,
executor: 'patch',
},
{
name: 'explicit SDK without it installed',
options: { agent: 'sdk' },
key: 'test-key',
sdk: true,
sdk: false,
installed: false,
refused: true,
},
{
name: 'explicit SDK',
options: { agent: 'sdk' },
key: 'test-key',
sdk: true,
executor: 'sdk',
},
{
name: 'explicit patch',
options: { agent: 'patch' },
key: 'test-key',
sdk: false,
executor: 'patch',
},
{ name: 'explicit SDK', options: { agent: 'sdk' }, key: 'test-key', sdk: true },
{ name: 'explicit patch', options: { agent: 'patch' }, key: 'test-key', sdk: false },
{ name: 'no key', options: {}, key: '', sdk: false },
{
name: 'no LLM overrides SDK',
Expand All @@ -143,37 +165,58 @@ describe('fix transaction', () => {
},
key: 'test-key',
sdk: false,
executor: 'patch',
},
{ name: 'OpenAI', options: { provider: 'openai' }, key: 'test-key', sdk: false },
{ name: 'Gemini', options: { provider: 'gemini' }, key: 'test-key', sdk: false },
])('$name selects the expected executor', async ({ options, key, sdk, installed = true }) => {
const { root, services } = fixture();
vi.stubEnv('ANTHROPIC_API_KEY', key);
const tests = vi.spyOn(services, 'tests');
const assist = vi.spyOn(sdkAgent, 'assistWithSdk').mockResolvedValue({
available: true,
inputTokens: 0,
outputTokens: 0,
costUsd: 0,
agent: 'sdk',
});
try {
const report = await fix(
{ cwd: root, only: 'zod', provider: 'anthropic', ...options },
{ ...services, sdkInstalled: async () => installed },
);
expect(assist).toHaveBeenCalledTimes(sdk ? 1 : 0);
expect(report.llm.agent).toBe(sdk ? 'sdk' : undefined);
const fellBack = !installed && !sdk;
expect(report.llm.sdkMissing).toBe(fellBack || undefined);
expect(formatFix(report).includes('@anthropic-ai/claude-agent-sdk')).toBe(fellBack);
expect(tests.mock.calls.length).toBeGreaterThanOrEqual(2);
expect(tests.mock.calls.every(([, , , , options]) => options?.fullSuite === true)).toBe(true);
} finally {
assist.mockRestore();
vi.unstubAllEnvs();
}
});
])(
'$name selects the expected executor',
async ({ options, key, sdk, installed = true, executor, refused }) => {
const { root, services } = fixture();
vi.stubEnv('ANTHROPIC_API_KEY', key);
const tests = vi.spyOn(services, 'tests');
const assist = vi.spyOn(sdkAgent, 'assistWithSdk').mockResolvedValue({
available: true,
inputTokens: 0,
outputTokens: 0,
costUsd: 0,
agent: 'sdk',
});
try {
const run = fix(
{ cwd: root, only: 'zod', provider: 'anthropic', ...options },
{ ...services, sdkUnavailable: async () => (installed ? undefined : 'not installed') },
);
// Asked for by name, the SDK never falls back: the run stops before any clone.
if (refused) {
await expect(run).rejects.toThrow(
/^--agent sdk cannot run: not installed; let Uptide reach the npm registry/,
);
expect(assist).not.toHaveBeenCalled();
return;
}
const report = await run;
expect(assist).toHaveBeenCalledTimes(sdk ? 1 : 0);
expect(report.llm.agent).toBe(sdk ? 'sdk' : undefined);
expect(report.llm.executor).toBe(executor);
const fellBack = !installed && !sdk;
expect(report.llm.sdkMissing).toBe(fellBack ? 'not installed' : undefined);
// The fallback is the first thing the summary and the PR body say after the title.
for (const text of [formatFix(report), renderMigration(report)])
expect(text.split('\n')[2]?.startsWith('> ⚠️ Assisted fixes use the patch executor')).toBe(
fellBack,
);
expect(renderMigration(report)).not.toContain('npx uptide with');
expect(tests.mock.calls.length).toBeGreaterThanOrEqual(2);
expect(tests.mock.calls.every(([, , , , options]) => options?.fullSuite === true)).toBe(
true,
);
} finally {
assist.mockRestore();
vi.unstubAllEnvs();
}
},
);

it('does not pass a run whose baseline cannot see a package the repository installs', async () => {
const { root, services } = fixture();
Expand Down
8 changes: 3 additions & 5 deletions packages/core/src/fix/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { noPackBlockers } from './no-pack.js';
import { uniqueSites } from './paired.js';
import { groupPeerBlockers, peerDescription } from './peer-preflight.js';
import { agentDecisionLines, testOutput } from './review-evidence.js';
import { sdkFallbackNote } from './sdk-agent.js';
import { stripeChangelog } from './stripe-changelog.js';
import { isTestFile } from './test-edits.js';
import type { FixDiagnostic, FixPhase, FixReport, FixSite, ReviewSection } from './types.js';
Expand Down Expand Up @@ -834,6 +835,8 @@ export function renderMigration(
? `## Pin the Stripe API version to ${report.apiVersion}`
: `## Upgrade ${report.package} ${from} → ${report.target}`;
const lines: (string | Piece)[] = [title, ''];
// Never a silent switch: the executor that ran is the first thing said when it is not the SDK.
if (report.llm.sdkMissing) lines.push(`> ⚠️ ${sdkFallbackNote(report.llm.sdkMissing)}`, '');
// Why the mechanical path stopped and the agent led: the first thing a reviewer reads.
if (report.agentFallback?.length)
lines.push(
Expand Down Expand Up @@ -994,11 +997,6 @@ export function renderMigration(
if (report.tier === 'generic' && report.sites.some((s) => s.outcome === 'agent'))
lines.push(`> ${GENERIC_NOTE(report.package)}`, '');
if (report.llm.disabled) lines.push('Assisted fixes disabled (--no-llm).', '');
if (mode === 'compact' && report.llm.sdkMissing)
lines.push(
'Assisted fixes used the patch agent: install @anthropic-ai/claude-agent-sdk next to uptide for the SDK executor (--agent patch silences this).',
'',
);
lines.push(...summaryRows(report), '');
if (mode === 'full' && budget !== undefined && report.package === 'stripe') {
for (const stored of report.reviewSections ?? []) {
Expand Down
Loading
Loading