diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d7dacd3f..1b220df4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,6 +87,13 @@ jobs: DC_REQUIRE_BWRAP: ${{ runner.os == 'Linux' && '1' || '0' }} run: pnpm test + # Exercise the installed SDK executable on both operating systems. The + # fixture uses a loopback Messages server and fake keys, never vendor APIs. + - name: Claude SDK executable smoke + env: + DEEPCODE_SDK_SMOKE: '1' + run: pnpm --filter @deepcode/app-server exec vitest run src/engines/claude-sdk.smoke.test.ts + - name: Build + app-server release gate run: pnpm release:check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 60bc7464..076033ac 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -56,6 +56,10 @@ jobs: env: DC_SANDBOX_NET_TEST: '1' run: pnpm test + - name: Claude SDK executable smoke + env: + DEEPCODE_SDK_SMOKE: '1' + run: pnpm --filter @deepcode/app-server exec vitest run src/engines/claude-sdk.smoke.test.ts - run: pnpm docs:check - run: pnpm release:check diff --git a/CHANGELOG.md b/CHANGELOG.md index f7f79cf4..a9c621d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.4.0] — 2026-10-26 + +### Multi-model runtime + +- Native Kimi and GLM profiles share provider routing, model budgets and scoped + credentials across the CLI and app-server clients. Unknown prices are shown + as unavailable. See [multi-model configuration](docs/multi-model.md). +- Claude Agent SDK 0.3.310 is an explicit **experimental** engine, with controlled + DeepCode tools, approvals, snapshots and isolated sessions. Native remains the + default. To roll back, select `engine: "native"` and start a new session; + existing SDK records are retained. +- Truncated responses and exhausted turn budgets report incomplete execution. + Summaries inherit the selected model and cancellation, credentials follow the + documented precedence, and user prompts can be cancelled. +- File mutations recheck permissions after approval; review restore respects + file restrictions, and SDK rollback records point to the pre-edit snapshot. + Hook processes and SDK resources are cleaned up when work stops. Cancellation + during the final path check or file preparation no longer starts a later write. + Additional writable directories work with the default sandbox mode; SDK + session forks are rejected before copying incomplete history. +- Real SDK executable tests run on Ubuntu and macOS against local fixtures; + Linux bwrap/netns, desktop protocol journeys and release gates also pass. + Real Kimi/GLM endpoints and long-task quality still need live acceptance. +- The VSIX uses native by default and does **not** bundle the optional SDK + executable. Signed macOS installers and npm publication require the release + credentials described in [Releasing DeepCode](docs/RELEASING.md). + +The following changes accumulated on main since v0.3.0 are also included. + ### ⚠️ Breaking - **`deepcode mcp serve` now applies your permission settings.** It executed diff --git a/apps/cli/package.json b/apps/cli/package.json index 6db72fa7..e198d04f 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "@oratis/deepcode", - "version": "0.3.0", + "version": "0.4.0", "description": "DeepCode CLI — DeepSeek-powered AI coding agent for real codebases", "license": "MIT", "type": "module", diff --git a/apps/cli/src/cli.ts b/apps/cli/src/cli.ts index a10a3dbf..e5e1c36d 100644 --- a/apps/cli/src/cli.ts +++ b/apps/cli/src/cli.ts @@ -5,6 +5,12 @@ import { CredentialsStore, + loadSettings, + gateUntrustedSettings, + DirectoryTrustStore, + defaultModel, + providerForModel, + PROVIDERS, VERSION, diagnoseSettings, fileContractWarnings, @@ -204,7 +210,19 @@ async function main(): Promise { // Onboarding if no creds const credsStore = new CredentialsStore(); const existing = await credsStore.load(); - if (!existing.apiKey && !existing.authToken && !process.env.DEEPSEEK_API_KEY) { + const loaded = await loadSettings({ cwd: process.cwd(), settingsPath: args.settingsFile }); + const trusted = await new DirectoryTrustStore().statusFor(process.cwd()); + const settings = gateUntrustedSettings(loaded, trusted).settings; + const selected = providerForModel(args.model ?? defaultModel(settings), settings.provider); + const keyEnv = settings.providers?.[selected]?.apiKeyEnv ?? PROVIDERS[selected].apiKeyEnv; + if ( + selected === 'deepseek' && + !existing.apiKey && + !existing.authToken && + !process.env[keyEnv] && + !(keyEnv === 'DEEPSEEK_API_KEY' && process.env.DEEPSEEK_AUTH_TOKEN) && + !settings.apiKeyHelper + ) { const result = await runOnboarding({ input: process.stdin, output: process.stdout, diff --git a/apps/cli/src/commands.test.ts b/apps/cli/src/commands.test.ts index 841430dd..28921eb1 100644 --- a/apps/cli/src/commands.test.ts +++ b/apps/cli/src/commands.test.ts @@ -103,12 +103,74 @@ describe('built-in command behavior', () => { await reg.match('/model deepseek-reasoner')!.cmd.run(['deepseek-reasoner'], ctx); expect(ctx.model).toBe('deepseek-reasoner'); }); + it.each(['kimi-k2.5', 'glm-4.7', 'custom/model'])( + '/model accepts %s and uses its output budget', + async (model) => { + const reg = new CommandRegistry(); + const ctx = makeContext(); + await reg.match('/model')!.cmd.run([model], ctx); + expect(ctx.model).toBe(model); + if (model === 'kimi-k2.5') { + const out = await reg.match('/effort')!.cmd.run(['max'], ctx); + expect(out.join('\n')).toContain('maxTokens=16384'); + } + }, + ); + it('/cost does not reprice mixed model usage using the current model', async () => { + const out = await new CommandRegistry() + .match('/cost')! + .cmd.run([], makeContext({ costModel: null })); + expect(out.join('\n')).toContain('Cost unavailable for mixed-model usage'); + }); + it('/clear creates a fresh SDK session while leaving the old session intact', async () => { + const sessions = new SessionManager({ root: sessRoot }); + const old = await sessions.create('/tmp/x'); + const ctx = makeContext({ sessions, sessionId: old.id, settings: { engine: 'claude-sdk' } }); + await new CommandRegistry().match('/clear')!.cmd.run([], ctx); + expect(ctx.sessionId).not.toBe(old.id); + expect(await sessions.load(old.id)).not.toBeNull(); + }); + it('keeps the current SDK model when a session already has history', async () => { + const ctx = makeContext({ + model: 'kimi-k2.5', + settings: { engine: 'claude-sdk' }, + history: [{ role: 'user', content: [{ type: 'text', text: 'old turn' }] }], + }); + const out = await new CommandRegistry().match('/model')!.cmd.run(['glm-4.7'], ctx); + expect(ctx.model).toBe('kimi-k2.5'); + expect(out.join('\n')).toContain('/clear'); + }); + it('passes slash-command cancellation into compaction without replacing history', async () => { + const ac = new AbortController(); + const ctx = makeContext({ + signal: ac.signal, + history: Array.from({ length: 15 }, () => ({ + role: 'user', + content: [{ type: 'text', text: 'keep' }], + })), + provider: { + name: 'mock', + runTurn: async (opts) => { + expect(opts.signal).toBe(ac.signal); + expect(opts.model).toBe('deepseek-chat'); + ac.abort(); + return { + content: [{ type: 'text', text: 'summary' }], + stopReason: 'end_turn', + usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, reasoningTokens: 0 }, + }; + }, + }, + }); + await new CommandRegistry().match('/compact')!.cmd.run([], ctx); + expect(ctx.newHistory).toBeUndefined(); + }); it('/model rejects invalid name', async () => { const reg = new CommandRegistry(); const ctx = makeContext(); - const out = await reg.match('/model wrong')!.cmd.run(['wrong'], ctx); - expect(out.join('\n')).toMatch(/Unknown model/); + const out = await reg.match('/model bad model')!.cmd.run(['bad model'], ctx); + expect(out.join('\n')).toMatch(/Invalid model/); expect(ctx.model).toBe('deepseek-chat'); // unchanged }); diff --git a/apps/cli/src/commands.ts b/apps/cli/src/commands.ts index 891c19c5..4d3c77ed 100644 --- a/apps/cli/src/commands.ts +++ b/apps/cli/src/commands.ts @@ -16,12 +16,15 @@ import type { } from '@deepcode/core'; import { describeSandboxMode, + validModelId, + MODEL_CATALOG, resolveSandboxMode, contextWindowFor, estimateCost, redact, writeSettings, - EFFORT_PARAMS, + modelParameters, + providerForModel, VERSION, type Credentials, type Effort, @@ -144,6 +147,8 @@ export interface SessionContext { voiceCapture?: () => Promise<{ transcript: string | null; lines: string[] }>; /** Set by /voice — the REPL pre-fills the next input line with this text. */ prefillInput?: string; + /** Null means cumulative usage spans models and cannot be repriced as one model. */ + costModel?: string | null; sessionId: string; sessions: SessionManager; usage: { @@ -176,6 +181,8 @@ export interface SessionContext { initFlow?: () => Promise; /** Current conversation history — REPL refreshes this before each command call. */ history?: StoredMessage[]; + /** Cancellation of a slash command that invokes a provider. */ + signal?: AbortSignal; /** Provider for commands that need to call the LLM (e.g. /rewind summarize). */ provider?: Provider; /** Set by /rewind to request history replacement. REPL applies after run. */ @@ -221,8 +228,11 @@ export const HelpCommand: SlashCommand = { export const ClearCommand: SlashCommand = { name: '/clear', - description: 'Clear conversation history (keeps session ID).', - run(_args, ctx) { + description: 'Clear conversation history (SDK starts a new session).', + async run(_args, ctx) { + if (ctx.settings.engine === 'claude-sdk') { + ctx.sessionId = (await ctx.sessions.create(ctx.cwd)).id; + } ctx.clearHistory = true; return ['Conversation history cleared.']; }, @@ -262,13 +272,17 @@ export const StatusCommand: SlashCommand = { export const ModelCommand: SlashCommand = { name: '/model', - description: 'Switch model: /model deepseek-chat | deepseek-reasoner', + description: 'Switch model: /model ', run(args, ctx) { if (args.length === 0) return [`Current model: ${ctx.model}`]; const next = args[0]!; - if (next !== 'deepseek-chat' && next !== 'deepseek-reasoner') { - return [`Unknown model "${next}". Valid: deepseek-chat | deepseek-reasoner`]; + if (!validModelId(next)) { + return [`Invalid model ID. Presets: ${MODEL_CATALOG.map((m) => m.id).join(' | ')}`]; } + if (ctx.settings.engine === 'claude-sdk' && ctx.history?.length && next !== ctx.model) + return [ + 'Claude SDK fixes the model per session. Use /clear, then /model to choose a new model.', + ]; ctx.model = next; return [`Model switched to ${next}.`]; }, @@ -306,7 +320,7 @@ export const PlanCommand: SlashCommand = { // Effort tier UI metadata surfaced by `/effort` with no args. // why: the maxTokens/temperature numbers are NOT defined here — they are read -// from EFFORT_PARAMS in @deepcode/core, the single source of truth the REPL and +// from modelParameters in @deepcode/core, the single source of truth the REPL and // headless paths actually send to the provider. A divergent hardcoded table // here previously told users "max = 32768 tokens, temp 0.7" while the provider // sent max_tokens=8192, temp=0.8 — pure misinformation. Only the human-readable @@ -321,32 +335,26 @@ const EFFORT_USE: Record = { max: 'Open-ended exploration. Burns tokens.', }; -const EFFORT_TIERS: Array<{ - name: Effort; - maxTokens: number; - temperature: number; - use: string; -}> = EFFORT_ORDER.map((name) => ({ - name, - maxTokens: EFFORT_PARAMS[name].maxTokens, - temperature: EFFORT_PARAMS[name].temperature, - use: EFFORT_USE[name], -})); - export const EffortCommand: SlashCommand = { name: '/effort', description: 'Set effort tier (interactive picker if no arg): /effort [low|medium|high|xhigh|max]', run(args, ctx) { + const id = providerForModel(ctx.model, ctx.settings.provider); + const tiers = EFFORT_ORDER.map((name) => ({ + name, + ...modelParameters(ctx.model, name, ctx.settings.providers?.[id], id), + use: EFFORT_USE[name], + })); if (args.length === 0) { // Selector UI — show the table; user picks via `/effort ` next turn. const lines = [`Current effort: ${ctx.effort}`, '']; lines.push('Available tiers:'); lines.push(' Tier maxTokens temperature Use case'); - for (const t of EFFORT_TIERS) { + for (const t of tiers) { const marker = t.name === ctx.effort ? '●' : ' '; lines.push( - ` ${marker} ${t.name.padEnd(7)} ${String(t.maxTokens).padStart(7)} ${t.temperature.toFixed(1).padStart(11)} ${t.use}`, + ` ${marker} ${t.name.padEnd(7)} ${String(t.maxTokens).padStart(7)} ${(t.temperature?.toFixed(1) ?? 'default').padStart(11)} ${t.use}`, ); } lines.push(''); @@ -354,13 +362,13 @@ export const EffortCommand: SlashCommand = { return lines; } const next = args[0]!; - const tier = EFFORT_TIERS.find((t) => t.name === next); + const tier = tiers.find((t) => t.name === next); if (!tier) { - return [`Unknown effort "${next}". Valid: ${EFFORT_TIERS.map((t) => t.name).join(' | ')}`]; + return [`Unknown effort "${next}". Valid: ${tiers.map((t) => t.name).join(' | ')}`]; } ctx.effort = next; return [ - `Effort switched to ${next}. (maxTokens=${tier.maxTokens}, temperature=${tier.temperature}, ${tier.use})`, + `Effort switched to ${next}. (maxTokens=${tier.maxTokens}, temperature=${tier.temperature ?? 'default'}, ${tier.use})`, ]; }, }; @@ -373,7 +381,12 @@ export const CostCommand: SlashCommand = { // Cache-aware pricing per docs/design/effort-levels.md §2.4. DeepSeek's // prompt caching is automatic server-side; cache-hit input tokens bill at // ~10% of a miss, so a stable prompt prefix across turns saves real money. - const c = estimateCost(ctx.usage, ctx.model); + const c = estimateCost(ctx.usage, ctx.costModel ?? ctx.model); + if (!c.available || ctx.costModel === null) + return [ + `Tokens — in: ${ctx.usage.inputTokens}, out: ${ctx.usage.outputTokens}`, + `Cost unavailable for ${ctx.costModel === null ? 'mixed-model usage' : ctx.model}; consult the provider usage dashboard.`, + ]; const cacheHits = Math.min(ctx.usage.cacheReadTokens, ctx.usage.inputTokens); const hitPct = (c.cacheHitRate * 100).toFixed(0); const lines = [ @@ -393,7 +406,8 @@ export const ContextCommand: SlashCommand = { description: 'Show context window usage.', run(_args, ctx) { const used = ctx.usage.inputTokens + ctx.usage.outputTokens; - const ctxMax = contextWindowFor(ctx.model); + const id = providerForModel(ctx.model, ctx.settings.provider); + const ctxMax = ctx.settings.providers?.[id]?.contextWindow ?? contextWindowFor(ctx.model); const pct = ((used / ctxMax) * 100).toFixed(1); return [ `Context: ${used.toLocaleString()} / ${ctxMax.toLocaleString()} (${pct}%)`, @@ -722,6 +736,10 @@ export const RewindCommand: SlashCommand = { } const action = (args[1] ?? 'code').toLowerCase(); + if (ctx.settings.engine === 'claude-sdk' && action !== 'code') + return [ + 'Claude SDK owns its conversation history. Use /clear to start a new session; file-only rewind remains available.', + ]; const cutoffMs = Date.parse(target.capturedAt); const currentHistory = ctx.history ?? []; @@ -754,7 +772,11 @@ export const RewindCommand: SlashCommand = { if (tail.length === 0) { return [`Nothing after snapshot #${target.seq} to summarize.`]; } - const result = await compact(tail, { provider: ctx.provider }); + const result = await compact(tail, { + provider: ctx.provider, + summarizerModel: ctx.model, + signal: ctx.signal, + }); // New history: head (verbatim) + the compacted tail ctx.newHistory = [...kept, ...result.history]; return [ @@ -769,7 +791,11 @@ export const RewindCommand: SlashCommand = { if (head.length === 0) { return [`Nothing before snapshot #${target.seq} to summarize.`]; } - const result = await compact(head, { provider: ctx.provider }); + const result = await compact(head, { + provider: ctx.provider, + summarizerModel: ctx.model, + signal: ctx.signal, + }); // New history: compacted head + tail (verbatim) ctx.newHistory = [...result.history, ...tail]; return [ @@ -939,12 +965,18 @@ export const CompactCommand: SlashCommand = { name: '/compact', description: 'Summarize the conversation so far to free up context.', async run(_args, ctx) { + if (ctx.settings.engine === 'claude-sdk') + return ['Claude SDK manages context compaction. Use /clear for a new session.']; if (!ctx.provider) return ['(/compact needs a provider — none configured.)']; const history = ctx.history ?? []; if (history.length === 0) return ['Nothing to compact yet.']; try { const { compact } = await import('@deepcode/core'); - const result = await compact(history, { provider: ctx.provider }); + const result = await compact(history, { + provider: ctx.provider, + summarizerModel: ctx.model, + signal: ctx.signal, + }); if (result.messagesRemoved === 0) { return ['Conversation is already short enough — nothing to compact.']; } diff --git a/apps/cli/src/headless.ts b/apps/cli/src/headless.ts index 50721350..3451be60 100644 --- a/apps/cli/src/headless.ts +++ b/apps/cli/src/headless.ts @@ -1,3 +1,4 @@ +import { configuredEngine } from '@deepcode/app-server'; // Headless one-shot mode: `deepcode -p "do X"` // Spec: docs/DEVELOPMENT_PLAN.md §5a (M8) — implemented earlier than scheduled. // @@ -18,8 +19,9 @@ import { BashTool, CredentialsStore, - DeepSeekProvider, - EFFORT_PARAMS, + ConfiguredProvider, + resolveProviderConnection, + defaultModel, HookDispatcher, HookTrustStore, ReadTool, @@ -34,7 +36,6 @@ import { connectAllMcpServers, expandMcpResourceRefs, gateUntrustedSettings, - contextWindowFor, findStyle, loadMemory, loadOutputStyles, @@ -44,7 +45,6 @@ import { type SandboxMode, loadSkills, makeSkillTool, - resolveCredentials, wirePlugins, collectPluginContributions, type AgentEvent, @@ -145,18 +145,14 @@ export async function runHeadless(opts: HeadlessOpts): Promise { ); } const credsStore = new CredentialsStore({ home: opts.home }); - const creds = await resolveCredentials({ - store: credsStore, - apiKeyHelper: settings.apiKeyHelper, - }); - if (!creds.apiKey && !creds.authToken) { - errOutput.write( - 'No DeepSeek credentials. Set DEEPSEEK_API_KEY or run interactive `deepcode` to onboard.\n', - ); + const model = opts.model ?? defaultModel(settings); + try { + await resolveProviderConnection({ settings, store: credsStore }, model); + } catch (error) { + errOutput.write(`${(error as Error).message}\n`); return 3; } - const model = opts.model ?? settings.model ?? 'deepseek-chat'; const mode = (opts.mode ?? settings.permissions?.defaultMode ?? 'default') as Mode; // A permissive mode chosen for the REPL is inherited by every unattended run // that reads the same settings file — including scheduled jobs firing at 3am. @@ -169,18 +165,14 @@ export async function runHeadless(opts: HeadlessOpts): Promise { ); } const effort = opts.effort ?? settings.effortLevel ?? 'medium'; - const { maxTokens, temperature } = EFFORT_PARAMS[effort as Effort] ?? EFFORT_PARAMS.medium; const maxTurns = opts.maxTurns ?? DEFAULT_HEADLESS_MAX_TURNS; // Only ever tightens — a job profile can narrow what is auto-approved but // never widen it (see tightenPermissions). const effectivePermissions = tightenPermissions(settings.permissions, opts.permissionsOverride); - const provider = new DeepSeekProvider({ - apiKey: creds.apiKey ?? '', - authToken: creds.authToken, - baseURL: creds.baseURL ?? settings.baseURL, - }); + const provider = new ConfiguredProvider({ settings, store: credsStore }); + const { maxTokens, temperature } = provider.parameters(model, effort as Effort); // ─── tools (with --allowedTools / --disallowedTools filter) ────────── let tools: ToolRegistry; @@ -333,6 +325,7 @@ export async function runHeadless(opts: HeadlessOpts): Promise { let exitCode = 0; try { const runtime = new RuntimeHost({ + engine: configuredEngine(settings, credsStore), provider, tools, cwd, @@ -362,7 +355,7 @@ export async function runHeadless(opts: HeadlessOpts): Promise { maxTurns, signal: ctrl.signal, session: { manager: sessions, id: session.id }, - autoCompact: { contextWindow: contextWindowFor(model), threshold: 0.8 }, + autoCompact: { contextWindow: provider.contextWindow(model), threshold: 0.8 }, // In headless mode there's no human to ask: auto-deny anything that // would normally need approval. Users wanting auto-yes should pass // --mode dontAsk or --mode bypassPermissions (gated by trust). @@ -374,7 +367,7 @@ export async function runHeadless(opts: HeadlessOpts): Promise { if (aborted) { exitCode = 5; - } else if (result.stopReason === 'max_turns') { + } else if (result.stopReason === 'max_turns' || result.stopReason === 'max_tokens') { exitCode = 4; } else if (result.stopReason === 'error') { exitCode = 3; diff --git a/apps/cli/src/repl-resume.test.ts b/apps/cli/src/repl-resume.test.ts index 6f582f7c..67fafa0b 100644 --- a/apps/cli/src/repl-resume.test.ts +++ b/apps/cli/src/repl-resume.test.ts @@ -3,7 +3,7 @@ // pointed at a throwaway root, so no live REPL or provider is needed. import { afterEach, describe, expect, it } from 'vitest'; -import { mkdtemp, rm } from 'node:fs/promises'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { SessionManager, type StoredMessage } from '@deepcode/core'; @@ -100,4 +100,32 @@ describe('resolveSession', () => { expect(r.seededHistory).toEqual([]); expect(r.notice).toMatch(/not found/i); }); + + it.each(['sdk', 'future', 'orphaned-sdk'])( + 'rejects %s forks before creating a new session or copying its projection', + async (kind) => { + const sm = await freshManager(); + const src = await sm.create('/proj', { model: 'kimi-k2.5' }); + await sm.append(src.id, text('SDK UI projection')); + const directory = join(sm.root, src.id); + await mkdir(directory, { recursive: true }); + if (kind === 'orphaned-sdk') await mkdir(join(directory, 'claude-sdk')); + else + await writeFile( + join(directory, 'engine.json'), + JSON.stringify({ + version: kind === 'future' ? 99 : 1, + engine: 'claude-sdk', + }), + ); + await expect( + resolveSession(sm, '/proj', 'deepseek-chat', { + resumeId: src.id, + forkSession: true, + }), + ).rejects.toThrow(/cannot be forked/); + expect((await sm.list()).map((s) => s.id)).toEqual([src.id]); + expect((await sm.load(src.id))!.messages).toEqual([text('SDK UI projection')]); + }, + ); }); diff --git a/apps/cli/src/repl.ts b/apps/cli/src/repl.ts index 7976a949..71cb8921 100644 --- a/apps/cli/src/repl.ts +++ b/apps/cli/src/repl.ts @@ -1,11 +1,14 @@ +import { configuredEngine } from '@deepcode/app-server'; // CLI REPL — readline-based interactive loop. // Spec: docs/DEVELOPMENT_PLAN.md §5 import { BashTool, CredentialsStore, - DeepSeekProvider, - EFFORT_PARAMS, + ConfiguredProvider, + resolveProviderConnection, + defaultModel, + type Provider, HookDispatcher, HookTrustStore, ReadTool, @@ -37,9 +40,7 @@ import { gateUntrustedSettings, loadSkills, loadSlashCommands, - contextWindowFor, makeSkillTool, - resolveCredentials, settingsPaths, wirePlugins, withAdditionalWritableDirs, @@ -171,6 +172,7 @@ export async function resolveSession( const n = loaded.messages.length; const plural = n === 1 ? '' : 's'; if (opts.forkSession) { + await sessions.assertForkable(sourceId); const forked = await sessions.create(cwd, { model: loaded.meta.model ?? model, title: loaded.meta.title, @@ -276,19 +278,16 @@ async function runReplSession(opts: ReplOpts, shells: ShellRegistry): Promise `${n}: failed to start`) ?? []), ], - initFlow: () => runInitFlow({ cwd, output, rl, provider, model, maxTokens, temperature }), + initFlow: () => + runInitFlow({ + cwd, + output, + rl: { question: (q) => rl.question(q, { signal: ctx.signal }) }, + provider, + model: ctx.model, + ...provider.parameters(ctx.model, ctx.effort as Effort), + signal: ctx.signal, + }), // M8: /voice records from the mic + transcribes via whisper.cpp, then the // loop pre-fills the next input line with the transcript (rl is created below). voiceCapture: () => captureVoice({ rl, output, settings, home: opts.home }), @@ -558,8 +562,7 @@ async function runReplSession(opts: ReplOpts, shells: ShellRegistry): Promise activeTurn?.abort()); rl.on('SIGINT', () => { + if (activeTurn && !activeTurn.signal.aborted) { + activeTurn.abort(); + output.write('\nCancelling the current turn…\n'); + return; + } ctrlCCount++; if (ctrlCCount >= 2) { output.write('\nGoodbye.\n'); @@ -676,7 +686,15 @@ async function runReplSession(opts: ReplOpts, shells: ShellRegistry): Promise match.cmd.run(match.args, ctx)) + .catch((error: Error) => [`Command stopped: ${error.message}`]) + .finally(() => { + activeTurn = undefined; + ctx.signal = undefined; + }); for (const line of lines) output.write(line + '\n'); output.write('\n'); if (ctx.clearHistory) { @@ -737,82 +755,103 @@ async function runReplSession(opts: ReplOpts, shells: ShellRegistry): Promise` - // switch redirects new messages to the resumed session. - session: { manager: sessions, id: ctx.sessionId }, - modeOverride: ctx.mode as Mode, - autoCompact: { contextWindow: contextWindowFor(ctx.model), threshold: 0.8 }, - // Session-scoped manager: the agent's TaskCreate calls land here too, so - // background tasks persist across turns and show up in /tasks. - taskManager: tasks, - // Session-scoped too, for the same reason: a shell opened this turn has to - // still be there next turn or it is just a slower Bash. Deliberately NOT - // given to the background-task runner below — two agents interleaving - // commands in one shell would each be wrong about its state. - shells, - approval: async (toolName, input, verdict) => { - output.write( - `\n ${palette.yellow('⏸')} Approve ${palette.bold(toolName)}? ${palette.dim(verdict.reason)}\n`, - ); - // Show what is about to happen. A Write gets diffed against the file on - // disk when it already exists, so an overwrite doesn't look like a - // creation. - let existing: string | undefined; - if (toolName === 'Write' && typeof input.file_path === 'string') { - try { - const { readFile } = await import('node:fs/promises'); - existing = await readFile(input.file_path, 'utf8'); - } catch { - /* new file — no baseline */ + const turnController = new AbortController(); + activeTurn = turnController; + const result = await runtime + .run({ + signal: turnController.signal, + systemPrompt, + userMessage: userInput, + history, + model: ctx.model, + ...provider.parameters(ctx.model, ctx.effort as Effort), + maxTurns: opts.maxTurns, + cwd: ctx.cwd, + // ctx.sessionId (not the launch `session.id`) so a live `/resume ` + // switch redirects new messages to the resumed session. + session: { manager: sessions, id: ctx.sessionId }, + modeOverride: ctx.mode as Mode, + autoCompact: { contextWindow: provider.contextWindow(ctx.model), threshold: 0.8 }, + // Session-scoped manager: the agent's TaskCreate calls land here too, so + // background tasks persist across turns and show up in /tasks. + taskManager: tasks, + // Session-scoped too, for the same reason: a shell opened this turn has to + // still be there next turn or it is just a slower Bash. Deliberately NOT + // given to the background-task runner below — two agents interleaving + // commands in one shell would each be wrong about its state. + shells, + approval: async (toolName, input, verdict) => { + output.write( + `\n ${palette.yellow('⏸')} Approve ${palette.bold(toolName)}? ${palette.dim(verdict.reason)}\n`, + ); + // Show what is about to happen. A Write gets diffed against the file on + // disk when it already exists, so an overwrite doesn't look like a + // creation. + let existing: string | undefined; + if (toolName === 'Write' && typeof input.file_path === 'string') { + try { + const { readFile } = await import('node:fs/promises'); + existing = await readFile(input.file_path, 'utf8'); + } catch { + /* new file — no baseline */ + } } - } - output.write(renderApprovalPreview(toolName, input, palette, existing)); - const answer = (await rl.question(' [y]es / [n]o / [a]lways: ')).trim().toLowerCase(); - if (answer === 'a' || answer === 'always') { - // Persist a bare-tool matcher to project-local settings so the next - // run of this tool from this project skips the prompt. - try { - const { localPath } = settingsPaths({ cwd: ctx.cwd }); - await appendAllowMatcher(localPath, toolName); - output.write(` ✓ Added "${toolName}" to ${localPath} permissions.allow\n`); - } catch (err) { - output.write(` ⚠ Could not persist always-allow: ${(err as Error).message}\n`); + output.write(renderApprovalPreview(toolName, input, palette, existing)); + const answer = ( + await rl.question(' [y]es / [n]o / [a]lways: ', { signal: turnController.signal }) + ) + .trim() + .toLowerCase(); + if (answer === 'a' || answer === 'always') { + // Persist a bare-tool matcher to project-local settings so the next + // run of this tool from this project skips the prompt. + try { + const { localPath } = settingsPaths({ cwd: ctx.cwd }); + await appendAllowMatcher(localPath, toolName); + output.write(` ✓ Added "${toolName}" to ${localPath} permissions.allow\n`); + } catch (err) { + output.write(` ⚠ Could not persist always-allow: ${(err as Error).message}\n`); + } + return 'always'; } - return 'always'; - } - return answer === 'y' || answer === 'yes'; - }, - askUser: async (req) => { - output.write(`\n ❓ ${req.question}\n`); - const opts = req.options ?? []; - opts.forEach((o, i) => { - output.write(` ${i + 1}. ${o.label}${o.description ? ` — ${o.description}` : ''}\n`); - }); - if (opts.length === 0) { - return (await rl.question(' Answer: ')).trim(); - } - const reply = ( - await rl.question(` Pick 1-${opts.length} (or type free text): `) - ).trim(); - const n = Number(reply); - if (Number.isInteger(n) && n >= 1 && n <= opts.length) { - return opts[n - 1]!.label; - } - return `Other: ${reply}`; - }, - onEvent: (e: AgentEvent) => formatEvent(output, e, palette, thinking), - }); + return answer === 'y' || answer === 'yes'; + }, + askUser: async (req) => { + output.write(`\n ❓ ${req.question}\n`); + const opts = req.options ?? []; + opts.forEach((o, i) => { + output.write( + ` ${i + 1}. ${o.label}${o.description ? ` — ${o.description}` : ''}\n`, + ); + }); + if (opts.length === 0) { + return (await rl.question(' Answer: ', { signal: turnController.signal })).trim(); + } + const reply = ( + await rl.question(` Pick 1-${opts.length} (or type free text): `, { + signal: turnController.signal, + }) + ).trim(); + const n = Number(reply); + if (Number.isInteger(n) && n >= 1 && n <= opts.length) { + return opts[n - 1]!.label; + } + return `Other: ${reply}`; + }, + onEvent: (e: AgentEvent) => formatEvent(output, e, palette, thinking), + }) + .catch((error: Error) => { + output.write(` Run could not start: ${error.message}\n`); + return null; + }) + .finally(() => { + activeTurn = undefined; + }); if (thinking) output.write(thinking.close()); + if (!result) continue; history = result.history; + ctx.costModel = + ctx.costModel === undefined ? ctx.model : ctx.costModel === ctx.model ? ctx.model : null; ctx.usage.inputTokens += result.usage.inputTokens; ctx.usage.outputTokens += result.usage.outputTokens; ctx.usage.reasoningTokens += result.usage.reasoningTokens; @@ -828,6 +867,13 @@ async function runReplSession(opts: ReplOpts, shells: ShellRegistry): Promise Promise }; - provider: DeepSeekProvider; + provider: Provider; model: string; maxTokens?: number; temperature?: number; + signal?: AbortSignal; }): Promise { const { cwd, output, rl, provider, model, maxTokens, temperature } = args; const path = await import('node:path'); @@ -933,7 +980,8 @@ async function runInitFlow(args: { // Phase 2: propose output.write(' ▎ /init — Phase 2/3: asking model to draft AGENTS.md...\n'); - const draft = await draftAgentsMd(provider, model, summary, maxTokens, temperature); + const draft = await draftAgentsMd(provider, model, summary, maxTokens, temperature, args.signal); + args.signal?.throwIfAborted(); // Phase 3: approve output.write('\n ▎ Proposed AGENTS.md:\n'); @@ -954,6 +1002,7 @@ async function runInitFlow(args: { const verb = exists ? 'Overwrite' : 'Write'; const ans = (await rl.question(` ${verb} ${target}? [y]es / [n]o: `)).trim().toLowerCase(); if (ans !== 'y' && ans !== 'yes') return null; + args.signal?.throwIfAborted(); await fsp.writeFile(target, draft, 'utf8'); return target; } @@ -986,11 +1035,12 @@ async function buildProjectSummary(cwd: string): Promise { } async function draftAgentsMd( - provider: DeepSeekProvider, + provider: Provider, model: string, summary: string, maxTokens?: number, temperature?: number, + signal?: AbortSignal, ): Promise { const sys = `You are drafting an AGENTS.md (the per-project agent-instructions file). Output ONLY the Markdown — no preface, no fences. Sections to include: @@ -1003,6 +1053,7 @@ async function draftAgentsMd( Keep it under 80 lines.`; const r = await provider.runTurn({ + signal, model, systemPrompt: sys, tools: [], diff --git a/apps/cli/vitest.config.ts b/apps/cli/vitest.config.ts index 6822f214..9ad39639 100644 --- a/apps/cli/vitest.config.ts +++ b/apps/cli/vitest.config.ts @@ -15,8 +15,11 @@ export default defineConfig({ globals: true, }, resolve: { - alias: { - '@deepcode/core': resolve(__dirname, '..', '..', 'packages', 'core', 'src', 'index.ts'), - }, + alias: [ + { + find: /^@deepcode\/core$/, + replacement: resolve(__dirname, '..', '..', 'packages', 'core', 'src', 'index.ts'), + }, + ], }, }); diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 7859e897..a9f597b3 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@deepcode/desktop", - "version": "0.3.0", + "version": "0.4.0", "private": true, "description": "DeepCode Mac desktop client — Tauri + React", "license": "MIT", diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 0825decf..9f7e430c 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -672,7 +672,7 @@ dependencies = [ [[package]] name = "deepcode_desktop" -version = "0.3.0" +version = "0.4.0" dependencies = [ "dirs 5.0.1", "libc", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index fb787a1a..3a5c70a2 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "deepcode_desktop" -version = "0.3.0" +version = "0.4.0" description = "DeepCode Mac desktop client" authors = ["oratis"] edition = "2021" diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 9c768e3c..4bdc6237 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "DeepCode", - "version": "0.3.0", + "version": "0.4.0", "identifier": "dev.deepcode.desktop", "build": { "frontendDist": "../dist", diff --git a/apps/desktop/src/components/InspectorPanel.tsx b/apps/desktop/src/components/InspectorPanel.tsx index 42cc23fa..23020f5d 100644 --- a/apps/desktop/src/components/InspectorPanel.tsx +++ b/apps/desktop/src/components/InspectorPanel.tsx @@ -153,7 +153,7 @@ export function InspectorPanel({
Spend - ¥ {costYuan.toFixed(4)} + {costYuan === null ? 'Unavailable' : `¥ ${costYuan.toFixed(4)}`}
); diff --git a/apps/desktop/src/lib/slash-commands.ts b/apps/desktop/src/lib/slash-commands.ts index d9765a10..3f082953 100644 --- a/apps/desktop/src/lib/slash-commands.ts +++ b/apps/desktop/src/lib/slash-commands.ts @@ -1,3 +1,4 @@ +import { MODEL_CATALOG, validModelId } from '@deepcode/core/dist/providers/catalog.js'; // Slash commands the desktop can actually serve. // // The CLI's 38 commands run against a SessionContext full of node:fs, a @@ -24,7 +25,7 @@ export interface SlashCommand { summary: string; } -export const MODELS = ['deepseek-chat', 'deepseek-reasoner'] as const; +export const MODELS = MODEL_CATALOG.map((model) => model.id); export const MODES: AgentMode[] = [ 'default', 'acceptEdits', @@ -38,7 +39,7 @@ export const EFFORTS: Effort[] = ['low', 'medium', 'high', 'xhigh', 'max']; export const DESKTOP_COMMANDS: SlashCommand[] = [ { name: '/help', summary: 'List the commands available here' }, { name: '/clear', summary: 'Start a new conversation' }, - { name: '/model', args: '', summary: 'Switch model (deepseek-chat | deepseek-reasoner)' }, + { name: '/model', args: '', summary: 'Switch model (DeepSeek, Kimi, GLM or custom ID)' }, { name: '/mode', args: '', @@ -116,7 +117,7 @@ export function parseSlash(input: string): SlashAction | null { case '/diff': return { kind: 'diff' }; case '/model': - return (MODELS as readonly string[]).includes(arg) + return validModelId(arg) ? { kind: 'set-model', value: arg } : { kind: 'error', message: `Usage: /model ${MODELS.join(' | ')}` }; case '/mode': diff --git a/apps/desktop/src/screens/Repl.tsx b/apps/desktop/src/screens/Repl.tsx index 41b5bfec..6829f383 100644 --- a/apps/desktop/src/screens/Repl.tsx +++ b/apps/desktop/src/screens/Repl.tsx @@ -1,3 +1,4 @@ +import { MODEL_CATALOG, modelParameters } from '@deepcode/core/dist/providers/catalog.js'; // REPL / Chat screen — design-aligned per docs/VISUAL_DESIGN.html #3. // // Layout: @@ -100,56 +101,24 @@ const MAX_RECENT_FILES = 8; // Effort / AgentMode and their value lists live with the slash commands that // validate them, so the palette and the dropdowns can't drift apart. -const EFFORT_OPTIONS: DropdownOption[] = [ - // `meta` is the per-turn output-token budget (maxTokens) the effort maps to in - // core's EFFORT_PARAMS — NOT a context size. DeepSeek caps output at 8192, so - // these top out at ~8k (they previously showed 4k–32k, which was impossible). - { - value: 'low', - label: 'Low', - meta: '1.5k', - description: 'Cheap & quick — short answers, simple edits (1.5k output).', - }, - { - value: 'medium', - label: 'Medium', - meta: '3k', - description: 'Balanced (3k output). Can truncate multi-file writes.', - }, - { - value: 'high', - label: 'High', - meta: '6k', - description: 'Default — longer output for multi-file edits (6k).', - }, - { - value: 'xhigh', - label: 'Extra High', - meta: '8k', - description: 'Deep reasoning, near max output (8k).', - }, - { - value: 'max', - label: 'Max', - meta: '8.2k', - description: "Max output (8192 — DeepSeek's hard cap). Slow & expensive.", - }, -]; +function effortOptions(model: string): DropdownOption[] { + return (['low', 'medium', 'high', 'xhigh', 'max'] as Effort[]).map((value) => { + const { maxTokens } = modelParameters(model, value); + return { + value, + label: value === 'xhigh' ? 'Extra High' : value[0]!.toUpperCase() + value.slice(1), + meta: `${(maxTokens / 1000).toFixed(1)}k`, + description: `Preset output budget: ${maxTokens} tokens. Custom profiles may override it.`, + }; + }); +} -const MODEL_OPTIONS: DropdownOption<'deepseek-chat' | 'deepseek-reasoner'>[] = [ - { - value: 'deepseek-chat', - label: 'DeepSeek-Chat', - meta: '128k', - description: 'Faster, cheaper. Best default.', - }, - { - value: 'deepseek-reasoner', - label: 'DeepSeek-Reasoner (R1)', - meta: '128k', - description: 'Chain-of-thought reasoning for hard problems.', - }, -]; +const MODEL_OPTIONS: DropdownOption[] = MODEL_CATALOG.map((model) => ({ + value: model.id, + label: model.id, + meta: `${Math.round(model.contextWindow / 1000)}k`, + description: `${model.provider} · ${model.reasoning ? 'Reasoning' : 'General'}`, +})); const MODE_OPTIONS: DropdownOption[] = [ { @@ -282,7 +251,7 @@ export function ReplScreen({ // Cumulative spend across all turns this conversation (¥). Tracked separately // from `usage` because the context bar wants the latest snapshot while cost // must sum every billed turn. - const [costYuan, setCostYuan] = useState(0); + const [costYuan, setCostYuan] = useState(0); // Inspector-only state — files touched (Write/Edit) + the agent's todo list. const [recentFiles, setRecentFiles] = useState([]); const [todos, setTodos] = useState([]); @@ -428,8 +397,8 @@ export function ReplScreen({ cacheReadTokens: e.cacheReadTokens ?? 0, }, modelRef.current, - ).totalYuan; - setCostYuan((c) => c + turnCost); + ); + setCostYuan((c) => (c === null || !turnCost.available ? null : c + turnCost.totalYuan)); break; } case 'error': @@ -673,7 +642,7 @@ export function ReplScreen({ case 'cost': { const total = usage.inputTokens + usage.outputTokens; say( - `Spend this conversation: ¥${costYuan.toFixed(4)}\n` + + `Spend this conversation: ${costYuan === null ? 'unavailable' : `¥${costYuan.toFixed(4)}`}\n` + `Tokens: ${usage.inputTokens.toLocaleString()} in · ` + `${usage.outputTokens.toLocaleString()} out · ${total.toLocaleString()} total`, ); @@ -1010,7 +979,7 @@ export function ReplScreen({ onChange={setModel} disabled={controlsLocked} dot - title="DeepSeek model" + title="Model" panelWidth={280} options={MODEL_OPTIONS} renderTrigger={(opt) => ( @@ -1027,7 +996,7 @@ export function ReplScreen({ disabled={controlsLocked} title="Effort — maxTokens + temperature" panelWidth={280} - options={EFFORT_OPTIONS} + options={effortOptions(model)} renderTrigger={(opt) => ( <> {opt.label} @@ -1067,7 +1036,7 @@ export function ReplScreen({ {fillPct.toFixed(1)}% - ¥ {costYuan.toFixed(4)} + {costYuan === null ? 'Cost unavailable' : `¥ ${costYuan.toFixed(4)}`} diff --git a/apps/desktop/src/types/inspector.ts b/apps/desktop/src/types/inspector.ts index 3be12d69..99332a41 100644 --- a/apps/desktop/src/types/inspector.ts +++ b/apps/desktop/src/types/inspector.ts @@ -25,7 +25,7 @@ export interface InspectorData { /** Latest provider round-trip token counts — drives the Context section. */ usage: { inputTokens: number; outputTokens: number }; /** Cumulative spend this conversation (¥) — shown in Session info. */ - costYuan: number; + costYuan: number | null; /** Active model id — Context denominator (contextWindowFor) + Session info. */ model: string; /** Permission mode — Session info. */ diff --git a/apps/server/package.json b/apps/server/package.json index 5f849bf3..304d7e31 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -34,7 +34,8 @@ }, "dependencies": { "@deepcode/core": "workspace:*", - "@deepcode/protocol": "workspace:*" + "@deepcode/protocol": "workspace:*", + "@modelcontextprotocol/sdk": "^1.30.0" }, "devDependencies": { "@types/node": "^22.10.0", @@ -44,5 +45,8 @@ }, "engines": { "node": ">=22" + }, + "optionalDependencies": { + "@anthropic-ai/claude-agent-sdk": "0.3.310" } } diff --git a/apps/server/scripts/build-sidecar.mjs b/apps/server/scripts/build-sidecar.mjs index 0eec3be6..3c205a55 100644 --- a/apps/server/scripts/build-sidecar.mjs +++ b/apps/server/scripts/build-sidecar.mjs @@ -16,6 +16,7 @@ await build({ entryPoints: [resolve(packageRoot, 'src', 'sidecar-entry.ts')], outfile: output, bundle: true, + external: ['@anthropic-ai/claude-agent-sdk'], platform: 'node', format: 'cjs', target: 'node22', diff --git a/apps/server/src/default-runtime.ts b/apps/server/src/default-runtime.ts index 3972fb0f..8d03a002 100644 --- a/apps/server/src/default-runtime.ts +++ b/apps/server/src/default-runtime.ts @@ -1,11 +1,12 @@ -import { CredentialsStore, resolveCredentials } from '@deepcode/core/credentials'; +import { configuredEngine } from './engines/claude-sdk.js'; +import { CredentialsStore } from '@deepcode/core/credentials'; import { DirectoryTrustStore, gateUntrustedSettings, HookTrustStore, loadSettings, } from '@deepcode/core/config'; -import { DeepSeekProvider } from '@deepcode/core/dist/providers/deepseek.js'; +import { ConfiguredProvider, defaultModel } from '@deepcode/core'; import { RuntimeHost, SAFE_READONLY_TOOLS } from '@deepcode/core/runtime'; import { SessionManager } from '@deepcode/core/sessions'; @@ -29,22 +30,9 @@ export function createDefaultTurnExecutor( const hookReview = await hookTrustStore.review(cwd, loaded, gate.settings.hooks); const settings = { ...gate.settings, hooks: hookReview.hooks }; const effectiveMode = resolveComposedMode(mode, context.modeExplicit, settings); - const credentials = await resolveCredentials({ - store: new CredentialsStore({ - directory: home, - forceFile: options.forceFileCredentials, - }), - apiKeyHelper: settings.apiKeyHelper, - }); - if (!credentials.apiKey && !credentials.authToken) { - throw new Error( - 'No DeepSeek credentials. Run `deepcode` once to onboard, or set DEEPSEEK_API_KEY.', - ); - } - const provider = new DeepSeekProvider({ - apiKey: credentials.apiKey ?? '', - authToken: credentials.authToken, - baseURL: credentials.baseURL ?? settings.baseURL, + const provider = new ConfiguredProvider({ + settings, + store: new CredentialsStore({ directory: home, forceFile: options.forceFileCredentials }), }); const composition = await composeRuntime({ cwd, @@ -58,6 +46,10 @@ export function createDefaultTurnExecutor( }); return { host: new RuntimeHost({ + engine: configuredEngine( + settings, + new CredentialsStore({ directory: home, forceFile: options.forceFileCredentials }), + ), provider, tools: composition.tools, cwd, @@ -73,8 +65,9 @@ export function createDefaultTurnExecutor( pluginDirs: composition.pluginDirs, }), systemPrompt: composition.systemPrompt, - model: composition.model, + model: defaultModel(settings), effort: composition.effort, + parameters: (model, effort) => provider.parameters(model, effort), diagnostics: composition.diagnostics, prepareUserMessage: composition.prepareUserMessage, close: composition.close, diff --git a/apps/server/src/engines/claude-sdk.smoke.test.ts b/apps/server/src/engines/claude-sdk.smoke.test.ts new file mode 100644 index 00000000..6560b971 --- /dev/null +++ b/apps/server/src/engines/claude-sdk.smoke.test.ts @@ -0,0 +1,141 @@ +import { createServer } from 'node:http'; +import { mkdtemp, rm, readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { RuntimeHost, ToolRegistry, SessionManager, WriteTool } from '@deepcode/core'; +import { CredentialsStore } from '@deepcode/core/credentials'; +import { ClaudeSdkEngine } from './claude-sdk.js'; + +// Real SDK/native executable, loopback model endpoint, fake key. No vendor traffic. +const smoke = process.env.DEEPCODE_SDK_SMOKE === '1' ? describe : describe.skip; +smoke('installed Claude SDK executable', () => { + it.each(['kimi-k2.5', 'glm-4.7'])( + 'runs gated tools and resumes %s using a loopback Messages fixture', + async (model) => { + const cwd = await mkdtemp(join(tmpdir(), 'dc-sdk-smoke-')); + let calls = 0; + const server = createServer(async (req, res) => { + let data = ''; + for await (const chunk of req) data += chunk; + if (req.url?.includes('count_tokens')) { + res.setHeader('content-type', 'application/json'); + res.end('{"input_tokens":10}'); + return; + } + if (!req.url?.includes('/messages')) { + res.writeHead(404); + res.end(); + return; + } + const body = JSON.parse(data); + expect(body.model).toBe(model); + expect(body.max_tokens).toBeLessThanOrEqual(8192); + const hasTool = (body.tools as Array<{ name: string }>).some( + (tool) => tool.name === 'mcp__deepcode__Write', + ); + expect(hasTool).toBe(true); + const useTool = calls++ === 0; + res.setHeader('content-type', 'text/event-stream'); + const event = (type: string, value: object) => + res.write(`event: ${type}\ndata: ${JSON.stringify({ type, ...value })}\n\n`); + event('message_start', { + message: { + id: `msg_${calls}`, + type: 'message', + role: 'assistant', + model: body.model, + content: [], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 10, output_tokens: 0 }, + }, + }); + if (useTool) { + event('content_block_start', { + index: 0, + content_block: { + type: 'tool_use', + id: 'call_write', + name: 'mcp__deepcode__Write', + input: {}, + }, + }); + event('content_block_delta', { + index: 0, + delta: { + type: 'input_json_delta', + partial_json: JSON.stringify({ file_path: 'proof.txt', content: 'verified' }), + }, + }); + } else { + event('content_block_start', { index: 0, content_block: { type: 'text', text: '' } }); + event('content_block_delta', { + index: 0, + delta: { type: 'text_delta', text: 'SDK fixture complete' }, + }); + } + event('content_block_stop', { index: 0 }); + event('message_delta', { + delta: { stop_reason: useTool ? 'tool_use' : 'end_turn', stop_sequence: null }, + usage: { output_tokens: 10 }, + }); + event('message_stop', {}); + res.end(); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const endpoint = `http://127.0.0.1:${(server.address() as { port: number }).port}`; + try { + const settings = { + engine: 'claude-sdk' as const, + providers: { kimi: { anthropicURL: endpoint }, glm: { anthropicURL: endpoint } }, + }; + const engine = new ClaudeSdkEngine({ + settings, + store: new CredentialsStore({ directory: join(cwd, 'keys'), forceFile: true }), + env: { MOONSHOT_API_KEY: 'fake-loopback-key', GLM_API_KEY: 'fake-loopback-key' }, + }); + const host = new RuntimeHost({ + engine, + provider: { + name: 'unused', + runTurn: async () => { + throw new Error('native called'); + }, + }, + tools: new ToolRegistry([WriteTool]), + cwd, + mode: 'default', + disableLedger: true, + }); + const manager = new SessionManager({ root: join(cwd, 'sessions') }); + const session = await manager.create(cwd); + const events: unknown[] = []; + const input = { + model, + systemPrompt: 'Use the provided tools.', + userMessage: 'Write proof.txt', + session: { manager, id: session.id }, + approval: async () => true, + maxTurns: 3, + onEvent: (event: unknown) => events.push(event), + }; + const first = await host.run(input); + expect(first.stopReason, JSON.stringify(events)).toBe('end_turn'); + expect(await readFile(join(cwd, 'proof.txt'), 'utf8')).toBe('verified'); + const second = await host.run({ + ...input, + history: first.history, + userMessage: 'Continue', + }); + expect(second.stopReason, JSON.stringify(events)).toBe('end_turn'); + expect(calls).toBeGreaterThanOrEqual(3); + } finally { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + await rm(cwd, { recursive: true, force: true }); + } + }, + 60_000, + ); +}); diff --git a/apps/server/src/engines/claude-sdk.test.ts b/apps/server/src/engines/claude-sdk.test.ts new file mode 100644 index 00000000..66999132 --- /dev/null +++ b/apps/server/src/engines/claude-sdk.test.ts @@ -0,0 +1,311 @@ +import { getEventListeners } from 'node:events'; +import { access, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + createSdkMcpServer, + type Options, + type Query, + type SDKMessage, +} from '@anthropic-ai/claude-agent-sdk'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { + RuntimeHost, + SessionManager, + ToolRegistry, + WriteTool, + HookDispatcher, +} from '@deepcode/core'; +import { CredentialsStore } from '@deepcode/core/credentials'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { ClaudeSdkEngine, configuredEngine } from './claude-sdk.js'; + +describe('Claude SDK engine boundary', () => { + let cwd: string; + let store: CredentialsStore; + let observed: Options; + let body: (client: Client) => Promise; + let closed: boolean; + let stopReason: string; + let failAfterResult: boolean; + let failSetup: boolean; + let failClose: boolean; + beforeEach(async () => { + cwd = await mkdtemp(join(tmpdir(), 'dc-sdk-test-')); + store = new CredentialsStore({ directory: join(cwd, 'credentials'), forceFile: true }); + closed = false; + failAfterResult = false; + failSetup = false; + failClose = false; + stopReason = 'end_turn'; + body = async () => {}; + }); + afterEach(async () => { + await rm(cwd, { recursive: true, force: true }); + }); + function engine() { + return new ClaudeSdkEngine({ + settings: {}, + store, + env: { MOONSHOT_API_KEY: 'fake-moon' }, + loadSdk: async () => ({ + createSdkMcpServer: (options) => { + if (failSetup) throw new Error('MCP setup failed'); + return createSdkMcpServer(options); + }, + query: ({ options }) => { + observed = options!; + const generator = (async function* () { + const client = new Client({ name: 'test', version: '1' }); + const [a, b] = InMemoryTransport.createLinkedPair(); + const config = options!.mcpServers!.deepcode; + if (!config || config.type !== 'sdk') throw new Error('Missing bridge'); + await Promise.all([client.connect(a), config.instance.connect(b)]); + try { + yield { + type: 'system', + subtype: 'init', + session_id: '12345678-1234-1234-1234-123456789012', + } as SDKMessage; + await body(client); + yield { + type: 'assistant', + message: { content: [{ type: 'text', text: 'done' }] }, + } as SDKMessage; + yield { + type: 'result', + subtype: 'success', + is_error: false, + stop_reason: stopReason, + num_turns: 1, + usage: { input_tokens: 5, output_tokens: 2 }, + session_id: '12345678-1234-1234-1234-123456789012', + } as SDKMessage; + if (failAfterResult) throw new Error('Transport failed after result'); + } finally { + await client.close(); + } + })(); + return Object.assign(generator, { + close: () => { + closed = true; + if (failClose) throw new Error('SDK query close failed'); + }, + }) as Query; + }, + }), + }); + } + const provider = { + name: 'unused', + runTurn: async () => { + throw new Error('Native loop must not run'); + }, + }; + function host(mode: 'default' | 'plan' | 'bypassPermissions' = 'default') { + return new RuntimeHost({ + engine: engine(), + provider, + tools: new ToolRegistry([WriteTool]), + cwd, + mode, + disableLedger: true, + }); + } + const turn = { + model: 'kimi-k2.5', + systemPrompt: 'test', + userMessage: 'test', + systemReminders: false, + }; + it('keeps native as the explicit rollback and never loads SDK by default', () => { + expect(configuredEngine({}, store)).toBeUndefined(); + expect(configuredEngine({ engine: 'native' }, store)).toBeUndefined(); + }); + it.each(['setup', 'close'] as const)('cleans up when SDK %s fails', async (stage) => { + failSetup = stage === 'setup'; + failClose = stage === 'close'; + const controller = new AbortController(); + const result = await host().run({ ...turn, signal: controller.signal }); + expect(result.stopReason).toBe('error'); + expect(getEventListeners(controller.signal, 'abort')).toHaveLength(0); + if (stage === 'close') { + expect(closed).toBe(true); + await expect(access(observed.env!.CLAUDE_CONFIG_DIR!)).rejects.toThrow(); + } + }); + it('does not report success if the SDK stream subsequently fails', async () => { + failAfterResult = true; + expect((await host().run(turn)).stopReason).toBe('error'); + }); + it.each(['missing', 'future-version'] as const)( + 'preserves history when the SDK binding is %s', + async (failure) => { + const manager = new SessionManager({ root: join(cwd, 'sessions') }); + const session = await manager.create(cwd); + const first = await host().run({ ...turn, session: { manager, id: session.id } }); + const bindingPath = join(manager.root, session.id, 'claude-sdk', 'binding.json'); + if (failure === 'missing') await rm(bindingPath); + else { + const binding = JSON.parse(await readFile(bindingPath, 'utf8')); + await writeFile(bindingPath, JSON.stringify({ ...binding, version: 99 })); + } + await expect( + host().run({ ...turn, history: first.history, session: { manager, id: session.id } }), + ).rejects.toThrow(failure === 'missing' ? 'binding is missing' : 'binding version'); + expect((await manager.load(session.id))?.messages).toEqual(first.history); + }, + ); + it('ignores unsupported hook types when all hooks are explicitly disabled', async () => { + const runtime = new RuntimeHost({ + engine: engine(), + provider, + tools: new ToolRegistry(), + cwd, + hooks: new HookDispatcher({ + disableAllHooks: true, + hooks: { PreToolUse: [{ hooks: [{ type: 'agent', agent: 'judge' }] }] }, + }), + }); + expect((await runtime.run(turn)).stopReason).toBe('end_turn'); + }); + it('refuses to reinterpret an on-disk native session even when the caller omits history', async () => { + const manager = new SessionManager({ root: join(cwd, 'sessions') }); + const session = await manager.create(cwd); + await manager.append(session.id, { + role: 'user', + content: [{ type: 'text', text: 'native history' }], + }); + await expect(host().run({ ...turn, session: { manager, id: session.id } })).rejects.toThrow( + 'Existing native history', + ); + expect((await manager.load(session.id))?.messages).toHaveLength(1); + }); + it('rejects unsupported hooks instead of silently ignoring a configured gate', async () => { + const runtime = new RuntimeHost({ + engine: engine(), + provider, + tools: new ToolRegistry(), + cwd, + hooks: new HookDispatcher({ + hooks: { PreToolUse: [{ hooks: [{ type: 'agent', agent: 'judge' }] }] }, + }), + }); + await expect(runtime.run(turn)).rejects.toThrow('does not support agent hooks'); + }); + it('preserves SDK truncation even when the result subtype says success', async () => { + stopReason = 'max_tokens'; + expect((await host().run(turn)).stopReason).toBe('max_tokens'); + }); + it('disables SDK built-ins and ambient settings, uses only scoped credentials', async () => { + await host().run(turn); + expect(observed.tools).toEqual([]); + expect(observed.settingSources).toEqual([]); + expect(observed.strictMcpConfig).toBe(true); + expect(observed.plugins).toEqual([]); + expect(observed.env?.ANTHROPIC_AUTH_TOKEN).toBe('fake-moon'); + expect(observed.env?.DEEPSEEK_API_KEY).toBeUndefined(); + expect(observed.env?.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined(); + expect(closed).toBe(true); + }); + it('refuses writes in plan mode even with an approval callback', async () => { + body = async (client) => { + const r = await client.callTool({ + name: 'Write', + arguments: { file_path: 'no.txt', content: 'no' }, + }); + expect(r.isError).toBe(true); + }; + await host('plan').run({ ...turn, approval: async () => true }); + await expect(access(join(cwd, 'no.txt'))).rejects.toThrow(); + }); + it('honors denial and does not execute a queued write', async () => { + body = async (client) => { + const r = await client.callTool({ + name: 'Write', + arguments: { file_path: 'no.txt', content: 'no' }, + }); + expect(r.isError).toBe(true); + }; + await host().run({ ...turn, approval: async () => false }); + await expect(access(join(cwd, 'no.txt'))).rejects.toThrow(); + }); + it('writes through the native gate and refuses symlink escape', async () => { + const outside = await mkdtemp(join(tmpdir(), 'dc-sdk-outside-')); + try { + await symlink(outside, join(cwd, 'escape')); + body = async (client) => { + expect( + ( + await client.callTool({ + name: 'Write', + arguments: { file_path: 'yes.txt', content: 'yes' }, + }) + ).isError, + ).toBe(false); + expect( + ( + await client.callTool({ + name: 'Write', + arguments: { file_path: 'escape/no.txt', content: 'no' }, + }) + ).isError, + ).toBe(true); + }; + const result = await host('bypassPermissions').run(turn); + expect( + result.history.some((message) => + message.content.some((block) => block.type === 'tool_use'), + ), + ).toBe(true); + expect( + result.history.some((message) => + message.content.some((block) => block.type === 'tool_result'), + ), + ).toBe(true); + expect(await readFile(join(cwd, 'yes.txt'), 'utf8')).toBe('yes'); + await expect(access(join(outside, 'no.txt'))).rejects.toThrow(); + } finally { + await rm(outside, { recursive: true, force: true }); + } + }); + it('resumes its SDK session and rejects switching engines in place', async () => { + const manager = new SessionManager({ root: join(cwd, 'sessions') }); + const session = await manager.create(cwd); + const first = await host().run({ ...turn, session: { manager, id: session.id } }); + await host().run({ ...turn, history: first.history, session: { manager, id: session.id } }); + expect(observed.resume).toBe('12345678-1234-1234-1234-123456789012'); + await expect( + host().run({ ...turn, model: 'kimi-custom', session: { manager, id: session.id } }), + ).rejects.toThrow('SDK connection changed'); + const native = new RuntimeHost({ provider, tools: new ToolRegistry([]), cwd }); + await expect(native.run({ ...turn, session: { manager, id: session.id } })).rejects.toThrow( + 'Start a new session', + ); + }); + it('cancels pending approval and drains tool work before returning', async () => { + const ac = new AbortController(); + body = async (client) => { + await client.callTool({ name: 'Write', arguments: { file_path: 'late', content: 'late' } }); + }; + const result = await host().run({ + ...turn, + signal: ac.signal, + approval: async () => { + ac.abort(); + return new Promise(() => {}); + }, + }); + expect(result.stopReason).toBe('aborted'); + await expect(access(join(cwd, 'late'))).rejects.toThrow(); + }); + it('stops an unattended run when approval policy requires abort', async () => { + body = async (client) => { + await client.callTool({ name: 'Write', arguments: { file_path: 'blocked', content: 'no' } }); + }; + const result = await host().run({ ...turn, unattended: true, onApprovalRequired: 'abort' }); + expect(result.stopReason).toBe('blocked'); + await expect(access(join(cwd, 'blocked'))).rejects.toThrow(); + }); +}); diff --git a/apps/server/src/engines/claude-sdk.ts b/apps/server/src/engines/claude-sdk.ts new file mode 100644 index 00000000..bb3dead1 --- /dev/null +++ b/apps/server/src/engines/claude-sdk.ts @@ -0,0 +1,349 @@ +import { randomUUID } from 'node:crypto'; +import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; +import { createEngineToolRunner, type AgentEngine } from '@deepcode/core/runtime'; +import { + PROVIDERS, + modelInfo, + resolveProviderConnection, + validateEndpoint, + type DeepCodeSettings, + type RunAgentOptions, + type RunAgentResult, + type StoredMessage, +} from '@deepcode/core'; +import { CredentialsStore } from '@deepcode/core/credentials'; +import type { Query, Options } from '@anthropic-ai/claude-agent-sdk'; + +type Sdk = Pick; +const BRIDGE_TOOLS = new Set([ + 'Read', + 'Write', + 'Edit', + 'Bash', + 'Grep', + 'Glob', + 'WebFetch', + 'WebSearch', + 'AskUserQuestion', + 'SubmitReviewFinding', + 'RestoreReviewAction', +]); + +/** Optional SDK runtime. The native engine does not import or execute it. */ +export class ClaudeSdkEngine implements AgentEngine { + readonly name = 'claude-sdk' as const; + constructor( + private readonly configuration: { + settings: DeepCodeSettings; + store: CredentialsStore; + env?: NodeJS.ProcessEnv; + loadSdk?: () => Promise; + }, + ) {} + async run(options: RunAgentOptions): Promise { + const unsupportedHooks = + options.hooks?.handlerTypes().filter((type) => type !== 'command' && type !== 'http') ?? []; + if (unsupportedHooks.length) + throw new Error( + `Claude SDK does not support ${unsupportedHooks.join(', ')} hooks. Select engine=native for this configuration.`, + ); + const connection = await resolveProviderConnection( + this.configuration, + options.model, + options.signal, + ); + if (connection.id === 'deepseek') + throw new Error('Claude SDK is experimental for Kimi/GLM. Select native for DeepSeek.'); + if (connection.profile?.baseURL && !connection.profile.anthropicURL) + throw new Error('A custom profile requires an explicit anthropicURL for Claude SDK.'); + const endpoint = validateEndpoint( + connection.profile?.anthropicURL ?? PROVIDERS[connection.id].anthropicURL!, + ); + let sdk: Sdk; + try { + sdk = await (this.configuration.loadSdk?.() ?? import('@anthropic-ai/claude-agent-sdk')); + } catch { + throw new Error( + 'Claude SDK optional runtime is unavailable. Install @anthropic-ai/claude-agent-sdk@0.3.310 beside the app-server, or select engine=native.', + ); + } + const persistent = !!options.session; + const directory = options.session + ? join(options.session.manager.root, options.session.id, 'claude-sdk') + : await mkdtemp(join(tmpdir(), 'deepcode-sdk-')); + await mkdir(directory, { recursive: true, mode: 0o700 }); + const bindingPath = join(directory, 'binding.json'); + let resume: string | undefined; + try { + const binding = JSON.parse(await readFile(bindingPath, 'utf8')) as { + version: number; + sessionId: string; + model: string; + provider: string; + endpoint: string; + }; + if (binding.version !== 1) throw new Error('Unsupported SDK session binding version'); + if ( + binding.model !== options.model || + binding.provider !== connection.id || + binding.endpoint !== endpoint + ) + throw new Error('SDK connection changed. Start a new session to change model or endpoint.'); + if (!/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(binding.sessionId)) + throw new Error('Invalid SDK session binding'); + resume = binding.sessionId; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + if ( + !resume && + options.session && + (options.history?.length || + (await options.session.manager.load(options.session.id))?.messages.length) + ) { + throw new Error( + 'SDK session binding is missing. Start a new session; existing history is preserved.', + ); + } + const controller = new AbortController(); + const abort = () => controller.abort(); + options.signal?.addEventListener('abort', abort, { once: true }); + if (options.signal?.aborted) abort(); + const history: StoredMessage[] = [ + ...(options.history ?? []), + { role: 'user', content: [{ type: 'text', text: options.userMessage ?? 'Continue' }] }, + ]; + const runner = createEngineToolRunner({ + ...options, + onBlocked: abort, + signal: controller.signal, + onEvent(event) { + // Stable UI/audit projection. The SDK's private transcript remains the resume source. + if (event.type === 'tool_use') + history.push({ + role: 'assistant', + content: [{ type: 'tool_use', id: event.id, name: event.name, input: event.input }], + }); + if (event.type === 'tool_result') + history.push({ + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: event.id, + content: event.result.content, + is_error: event.result.isError, + }, + ], + }); + options.onEvent?.(event); + }, + }); + const usage = { inputTokens: 0, outputTokens: 0, reasoningTokens: 0, cacheReadTokens: 0 }; + let query: Query | undefined; + let mcp: ReturnType | undefined; + let stopReason: RunAgentResult['stopReason'] = 'error'; + let turnsUsed = 0; + try { + const definitions = options.tools + .definitions() + .filter( + (tool) => + BRIDGE_TOOLS.has(tool.name) && + (!options.allowedTools || options.allowedTools.includes(tool.name)), + ); + const allowedNames = new Set(definitions.map((tool) => tool.name)); + mcp = sdk.createSdkMcpServer({ name: 'deepcode', version: '1.0.0', tools: [] }); + mcp.instance.server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: definitions.map((tool) => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema as { type: 'object' }, + })), + })); + mcp.instance.server.setRequestHandler(CallToolRequestSchema, async (request) => { + if (!allowedNames.has(request.params.name)) + return { content: [{ type: 'text', text: 'Tool unavailable' }], isError: true }; + const result = await runner.run( + request.params.name, + request.params.arguments ?? {}, + randomUUID(), + ); + return { + content: [{ type: 'text', text: result.content }], + isError: result.isError ?? false, + }; + }); + const environment: Record = { + PATH: process.env.PATH ?? '', + HOME: directory, + CLAUDE_CONFIG_DIR: directory, + TMPDIR: tmpdir(), + LANG: 'en_US.UTF-8', + ANTHROPIC_BASE_URL: endpoint, + ANTHROPIC_AUTH_TOKEN: connection.apiKey, + ANTHROPIC_MODEL: options.model, + ANTHROPIC_DEFAULT_OPUS_MODEL: options.model, + ANTHROPIC_DEFAULT_SONNET_MODEL: options.model, + ANTHROPIC_DEFAULT_HAIKU_MODEL: options.model, + CLAUDE_CODE_SUBAGENT_MODEL: options.model, + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1', + CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: '1', + CLAUDE_CODE_MAX_OUTPUT_TOKENS: String( + Math.min( + options.maxTokens ?? 8192, + connection.profile?.maxOutput ?? modelInfo(options.model)?.maxOutput ?? 8192, + ), + ), + }; + const sdkOptions: Options = { + cwd: options.cwd, + model: options.model, + systemPrompt: options.systemPrompt, + tools: [], + settingSources: [], + plugins: [], + strictMcpConfig: true, + permissionMode: 'default', + allowedTools: [], + // SDK approval only routes the call. The bridge always enforces DeepCode policy. + canUseTool: async (name, input) => + name.startsWith('mcp__deepcode__') && + allowedNames.has(name.slice('mcp__deepcode__'.length)) + ? { behavior: 'allow', updatedInput: input } + : { behavior: 'deny', message: 'Only DeepCode gated tools are available' }, + mcpServers: { deepcode: mcp }, + env: environment, + maxTurns: options.maxTurns ?? 20, + abortController: controller, + persistSession: persistent, + resume, + includePartialMessages: true, + verbatimPrompts: true, + }; + controller.signal.throwIfAborted(); + query = sdk.query({ prompt: options.userMessage ?? 'Continue', options: sdkOptions }); + for await (const message of query) { + if (controller.signal.aborted) break; + if (message.type === 'system' && message.subtype === 'init' && persistent) { + const temporary = `${bindingPath}.${randomUUID()}.tmp`; + await writeFile( + temporary, + JSON.stringify({ + version: 1, + sessionId: message.session_id, + provider: connection.id, + model: options.model, + endpoint, + }), + { mode: 0o600 }, + ); + if (controller.signal.aborted) { + await rm(temporary, { force: true }); + break; + } + await rename(temporary, bindingPath); + } else if ( + message.type === 'stream_event' && + message.event.type === 'content_block_delta' + ) { + const delta = message.event.delta; + if (delta.type === 'text_delta') + options.onEvent?.({ type: 'text_delta', text: delta.text }); + if (delta.type === 'thinking_delta') + options.onEvent?.({ type: 'thinking_delta', text: delta.thinking }); + } else if (message.type === 'assistant') { + const content = message.message.content + .filter((block) => block.type === 'text') + .map((block) => ({ + type: 'text' as const, + text: block.type === 'text' ? block.text : '', + })); + if (content.length) history.push({ role: 'assistant', content }); + } else if (message.type === 'result') { + turnsUsed = message.num_turns; + usage.inputTokens = + message.usage.input_tokens + + (message.usage.cache_read_input_tokens ?? 0) + + (message.usage.cache_creation_input_tokens ?? 0); + usage.outputTokens = message.usage.output_tokens; + usage.cacheReadTokens = message.usage.cache_read_input_tokens ?? 0; + stopReason = + message.subtype === 'success' && !message.is_error + ? message.stop_reason === 'max_tokens' + ? 'max_tokens' + : message.stop_reason === 'stop_sequence' + ? 'stop_sequence' + : message.stop_reason === 'refusal' + ? 'blocked' + : 'end_turn' + : message.subtype === 'error_max_turns' + ? 'max_turns' + : 'error'; + if (stopReason === 'error') + options.onEvent?.({ type: 'error', error: `Claude SDK stopped: ${message.subtype}` }); + } + } + } catch (error) { + stopReason = 'error'; + if (!controller.signal.aborted) + options.onEvent?.({ + type: 'error', + error: `Claude SDK request failed: ${(error as Error).name}`, + }); + } finally { + // Stop and drain all tool work even if the SDK fails during setup or close. + const cancelled = controller.signal.aborted; + controller.abort(); + try { + query?.close(); + } catch { + stopReason = 'error'; + options.onEvent?.({ type: 'error', error: 'Claude SDK failed to close its query' }); + } + await runner.drain(); + try { + await mcp?.instance.close(); + } catch { + stopReason = 'error'; + options.onEvent?.({ type: 'error', error: 'Claude SDK failed to close its tool bridge' }); + } + options.signal?.removeEventListener('abort', abort); + if (cancelled || options.signal?.aborted) stopReason = 'aborted'; + if (runner.blocked && !options.signal?.aborted) stopReason = 'blocked'; + if (!persistent) await rm(directory, { recursive: true, force: true }); + } + if (options.session && options.persistSessionMessages !== false && stopReason !== 'aborted') { + for (const message of history.slice(options.history?.length ?? 0)) { + if (options.signal?.aborted) { + stopReason = 'aborted'; + break; + } + await options.session.manager.append(options.session.id, message); + } + } + await options.hooks?.dispatch({ + event: 'Stop', + cwd: options.cwd, + signal: options.signal, + triggeredAt: new Date().toISOString(), + payload: { stopReason, turnsUsed }, + }); + if (options.signal?.aborted) stopReason = 'aborted'; + options.onEvent?.({ type: 'usage', ...usage }); + options.onEvent?.({ type: 'turn_complete', stopReason, message: history.at(-1) }); + return { history, turnsUsed, usage, stopReason }; + } +} + +export function configuredEngine( + settings: DeepCodeSettings, + store: CredentialsStore, +): AgentEngine | undefined { + if (settings.engine === undefined || settings.engine === 'native') return undefined; + if (settings.engine !== 'claude-sdk') throw new Error('Unknown Agent engine'); + return new ClaudeSdkEngine({ settings, store }); +} diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 7e39c221..50c5b767 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -10,3 +10,5 @@ export * from './structured-logger.js'; export * from './diagnostic-export.js'; export * from './workspace-diff.js'; export { capabilitiesFor } from './capabilities.js'; + +export { configuredEngine, ClaudeSdkEngine } from './engines/claude-sdk.js'; diff --git a/apps/server/src/runtime-executor.test.ts b/apps/server/src/runtime-executor.test.ts index ec50ead3..47d81945 100644 --- a/apps/server/src/runtime-executor.test.ts +++ b/apps/server/src/runtime-executor.test.ts @@ -107,6 +107,43 @@ class ToolProvider implements Provider { } describe('RuntimeHostExecutor', () => { + it.each(['max_tokens', 'error', 'max_turns', 'blocked'] as const)( + 'projects %s as failed with an explicit reason', + async (stopReason) => { + const host = new RuntimeHost({ + provider: new StreamingProvider(), + tools: new ToolRegistry(), + cwd: '/workspace', + }); + vi.spyOn(host, 'run').mockResolvedValue({ + history: [], + turnsUsed: 1, + usage: { inputTokens: 0, outputTokens: 0, reasoningTokens: 0, cacheReadTokens: 0 }, + stopReason, + }); + const result = await new RuntimeHostExecutor({ createHost: () => host }).execute({ + thread, + turn: { + id: 'limited', + threadId: thread.id, + status: 'in_progress', + startedAt: '', + items: [], + }, + input: { text: 'work' }, + signal: new AbortController().signal, + publishDelta: () => undefined, + ...protocolCallbacks(), + }); + expect(result.status).toBe('failed'); + expect(result.items).toContainEqual( + expect.objectContaining({ + type: 'error', + payload: expect.objectContaining({ code: stopReason }), + }), + ); + }, + ); it('exposes only the restore tool to a canonical revert turn', async () => { const provider = new StreamingProvider(); const tools = new ToolRegistry(); diff --git a/apps/server/src/runtime-executor.ts b/apps/server/src/runtime-executor.ts index 9d47b690..dec18cfd 100644 --- a/apps/server/src/runtime-executor.ts +++ b/apps/server/src/runtime-executor.ts @@ -7,7 +7,7 @@ import { type SessionManager, type StoredMessage, } from '@deepcode/core'; -import { EFFORT_PARAMS } from '@deepcode/core/dist/providers/deepseek.js'; +import { modelParameters } from '@deepcode/core'; import type { CompletedItem, ThreadSnapshot } from '@deepcode/protocol'; import type { TurnExecutionArgs, TurnExecutionItem, TurnExecutor } from './server.js'; @@ -35,6 +35,7 @@ export interface RuntimeHostLease { systemPrompt?: string; model?: string; effort?: Effort; + parameters?: (model: string, effort: Effort) => ReturnType; diagnostics?: Array<{ source: string; code: string; @@ -53,8 +54,7 @@ export interface RuntimeHostLease { close?: () => Promise | void; } -const DEFAULT_SYSTEM_PROMPT = - 'You are DeepCode, an AI coding assistant powered by DeepSeek. Be concise and accurate.'; +const DEFAULT_SYSTEM_PROMPT = 'You are DeepCode, an AI coding assistant. Be concise and accurate.'; export class RuntimeHostExecutor implements TurnExecutor { constructor(private readonly options: RuntimeHostExecutorOptions) {} @@ -97,16 +97,17 @@ export class RuntimeHostExecutor implements TurnExecutor { const streamingItemId = `${args.turn.id}-assistant`; const events: AgentEvent[] = []; const effort = parseEffort(args.input.effort ?? lease.effort); - const effortParams = EFFORT_PARAMS[effort]; + const model = + typeof args.input.model === 'string' + ? args.input.model + : (lease.model ?? this.options.model ?? 'deepseek-chat'); + const effortParams = lease.parameters?.(model, effort) ?? modelParameters(model, effort); const result = await lease.host.run({ cwd: args.thread.cwd, systemPrompt: lease.systemPrompt ?? this.options.systemPrompt ?? DEFAULT_SYSTEM_PROMPT, userMessage: text, history, - model: - typeof args.input.model === 'string' - ? args.input.model - : (lease.model ?? this.options.model ?? 'deepseek-chat'), + model, maxTokens: effortParams.maxTokens, temperature: effortParams.temperature, ...(reviewAction?.kind === 'revert' ? { allowedTools: ['RestoreReviewAction'] } : {}), @@ -164,7 +165,15 @@ export class RuntimeHostExecutor implements TurnExecutor { ...reviewFindingsFromEvents(events), ...completedItemsFromMessages(newMessages, text), ]; - if (result.stopReason === 'error') { + const successful = result.stopReason === 'end_turn' || result.stopReason === 'stop_sequence'; + if (!successful) { + items.push({ + type: 'error', + payload: { + code: result.stopReason, + message: `Run stopped: ${result.stopReason}. Continue the task when ready.`, + }, + }); const error = [...events].reverse().find((event) => event.type === 'error'); if (error?.type === 'error') { items.push({ type: 'error', payload: { message: error.error } }); @@ -172,7 +181,7 @@ export class RuntimeHostExecutor implements TurnExecutor { } return { items, - status: result.stopReason === 'error' ? ('failed' as const) : ('completed' as const), + status: successful ? ('completed' as const) : ('failed' as const), }; } finally { await lease.close?.(); diff --git a/apps/server/src/store.test.ts b/apps/server/src/store.test.ts index c24fe91c..f27b4750 100644 --- a/apps/server/src/store.test.ts +++ b/apps/server/src/store.test.ts @@ -1,9 +1,9 @@ -import { mkdtemp, rm } from 'node:fs/promises'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { SessionManager, writeMeta } from '@deepcode/core/sessions'; -import type { ThreadSnapshot } from '@deepcode/protocol'; +import { ProtocolRuntime, type ThreadSnapshot } from '@deepcode/protocol'; import { afterEach, describe, expect, it } from 'vitest'; import { CanonicalThreadStore } from './store.js'; @@ -26,6 +26,33 @@ async function fixture() { } describe('CanonicalThreadStore', () => { + it.each(['native', 'sdk', 'future', 'orphaned-sdk'])( + 'enforces %s engine history boundaries when forking a protocol thread', + async (kind) => { + const { store, sessions } = await fixture(); + const runtime = new ProtocolRuntime({ store }); + const source = await runtime.startThread('/workspace'); + const directory = join(sessions.root, source.id); + await mkdir(directory, { recursive: true }); + if (kind === 'orphaned-sdk') await mkdir(join(directory, 'claude-sdk')); + else + await writeFile( + join(directory, 'engine.json'), + JSON.stringify({ + version: kind === 'future' ? 99 : 1, + engine: kind === 'native' ? 'native' : 'claude-sdk', + }), + ); + if (kind === 'native') { + expect((await runtime.forkThread(source.id)).id).not.toBe(source.id); + expect(await sessions.list()).toHaveLength(2); + } else { + await expect(runtime.forkThread(source.id)).rejects.toThrow(/cannot be forked/); + expect((await sessions.list()).map((s) => s.id)).toEqual([source.id]); + expect((await store.list()).map((s) => s.id)).toEqual([source.id]); + } + }, + ); it('materializes protocol history into the canonical session index', async () => { const { store, sessions } = await fixture(); const thread: ThreadSnapshot = { diff --git a/apps/server/src/store.ts b/apps/server/src/store.ts index 32b47b62..fb3322cf 100644 --- a/apps/server/src/store.ts +++ b/apps/server/src/store.ts @@ -101,6 +101,10 @@ export class CanonicalThreadStore implements ThreadStore { this.sessions = new SessionManager({ root: sessionsDirectory }); } + async assertForkable(threadId: string): Promise { + await this.sessions.assertForkable(threadId); + } + async load(threadId: string): Promise { const snapshot = await this.snapshots.load(threadId); if (snapshot) return snapshot; diff --git a/apps/vscode/package.json b/apps/vscode/package.json index 58d92bc9..342e5483 100644 --- a/apps/vscode/package.json +++ b/apps/vscode/package.json @@ -63,13 +63,8 @@ "deepcode.model": { "type": "string", "default": "deepseek-chat", - "enum": [ - "deepseek-chat", - "deepseek-reasoner", - "deepseek-v4-flash", - "deepseek-v4-pro" - ], - "description": "DeepSeek model to use." + "minLength": 1, + "description": "Model ID (for example deepseek-chat, kimi-k2.5, glm-4.7, or a custom provider model). Credentials are resolved by the backend." }, "deepcode.effort": { "type": "string", @@ -81,7 +76,7 @@ "xhigh", "max" ], - "description": "Effort tier (affects maxTokens + temperature)." + "description": "Output budget tier; effective parameters depend on the selected provider." } } }, diff --git a/apps/vscode/scripts/build.mjs b/apps/vscode/scripts/build.mjs index d75ccdfd..7a7ca8bb 100644 --- a/apps/vscode/scripts/build.mjs +++ b/apps/vscode/scripts/build.mjs @@ -21,7 +21,7 @@ await Promise.all([ platform: 'node', format: 'cjs', target: 'node22', - external: ['vscode'], + external: ['vscode', '@anthropic-ai/claude-agent-sdk'], define: { __DEEPCODE_SETTINGS_SCHEMA__: JSON.stringify(settingsSchema), 'import.meta.url': '__deepcode_import_meta_url', @@ -40,6 +40,7 @@ await Promise.all([ format: 'cjs', target: 'node22', minify: true, + external: ['@anthropic-ai/claude-agent-sdk'], sourcemap: false, legalComments: 'none', define: { diff --git a/docs/CODEX_ALIGNMENT_PLAN.md b/docs/CODEX_ALIGNMENT_PLAN.md index dbdeb489..a4fbacc7 100644 --- a/docs/CODEX_ALIGNMENT_PLAN.md +++ b/docs/CODEX_ALIGNMENT_PLAN.md @@ -1,5 +1,9 @@ # DeepCode × Codex 整体改造计划 +> 2026-10-24 多模型方向补充:最新 main 的复审、实施进度与 Claude Code 引擎决策见 +> [ADR 0002](adr/0002-multi-model-agent-engine.md)。该 ADR 区分现有自研运行时、模型协议适配与 +> Claude Agent SDK 引擎,记录实施顺序、验收及回滚条件。原生多模型接入已实现;SDK 为显式启用的实验引擎,native 仍为默认。 + > 状态:正反方审议与仲裁范围内的实现已完成,等待 stacked draft PR 审阅/合并
> 基线:`main@38fcc3a`
> 日期:2026-08-01
diff --git a/docs/adr/0002-multi-model-agent-engine.md b/docs/adr/0002-multi-model-agent-engine.md new file mode 100644 index 00000000..f1c3e5bc --- /dev/null +++ b/docs/adr/0002-multi-model-agent-engine.md @@ -0,0 +1,178 @@ +# ADR 0002:Claude Code 引擎与多模型接入审查及对齐方案 + +- 日期:2026-10-24(2026-10-25 提交前复审) +- 状态:已实施 P1 修复与原生多模型接入;SDK 实验引擎已实现,尚未通过真实厂商及分发验收 +- 审查基线:`origin/main@cedeb85` +- 范围:模型适配、Agent 生命周期、配置与凭证、会话、权限、CLI/headless、桌面、VS Code/LSP、构建与发布门禁 + +## 1. 结论与决策 + +目标调整为:DeepCode 提供统一的编码产品与安全边界,支持 DeepSeek、Kimi、GLM 等模型,并验证复用 Claude Code Agent 引擎的可行性。 + +审查基线是自研 Agent,不是 Claude Code 的封装。core 依赖 OpenAI SDK,CLI 与 server 显式构造 `DeepSeekProvider`,仓库没有 Claude Agent SDK 依赖。当时的 Claude 配置兼容与工具命名不能视为已经复用 Claude Code 框架。下文发现的行号指向审查基线,实施后的代码见第 8 节。 + +建议保留现有 app-server、Thread/Turn/Item、客户端和数据兼容层,在可信后端引入 `AgentEngine` 接口。以 Claude Agent SDK 实验引擎验证 Kimi/GLM;原生引擎保持默认和回滚入口。通过兼容、安全与打包验收后,再决定是否将 Claude 引擎设为默认。用户选择的模型不应强制绑定某个引擎。 + +Claude SDK 已包含 Agent loop、工具与上下文管理,属于引擎替换;不能作为 `Provider.runTurn()` 内的模型请求适配器,否则会产生两层循环和两套工具执行权。 + +## 2. 最新代码与审查方法 + +原工作区 `docs/v0.3.0-report@04c09e8` 无未提交变更。执行 `git pull --ff-only` 后,该分支自身已最新,但与最新 main 相比有 1 个独立提交、落后 38 个提交。为保留文档分支,在独立工作区审查最新 main,未将分叉历史强行合并。 + +审查覆盖生产入口与关键调用链、相关单元/协议测试、CI 和发布门禁。不是逐行形式验证。复审后增加了针对凭证、取消、路径与 SDK 工具桥接的测试;实际验证范围见第 8 节。 + +## 3. 发现:按修复优先级排序 + +| 编号 | 级别 | 代码证据与触发条件 | 影响与建议 | +| ---- | ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| F1 | P1 | `packages/core/src/agent.ts:680`:provider 返回非 `tool_use` 一律 `finish('end_turn')`;`apps/server/src/runtime-executor.ts:172` 附近只将 `error` 映射为 failed | `max_tokens` 被标为正常完成;`max_turns`、`blocked` 也缺少客户端可区分的结束原因。保留 stop reason,增加 termination reason 与可恢复提示;不自动无限续写。 | +| F2 | P1 | `packages/core/src/compaction/index.ts:68` 默认摘要模型为 `deepseek-chat`;`agent.ts` 自动压缩未提供当前模型 fallback | 将 base URL 改成 Kimi/GLM 后,长任务摘要仍向该端点发送 DeepSeek 模型名。建立 main/summary/subagent/approval 模型角色解析,默认继承当前 provider 的有效模型。 | +| F3 | P1 | `CompactionOpts` 无 AbortSignal,摘要 `runTurn()` 没传 signal;`agent.ts:1054` 等待摘要完成 | 压缩期间取消无法中止该模型请求,取消延迟与后续 history 替换缺少保证。signal 贯穿压缩、hook 与提交边界,取消后禁止替换 history。 | +| F4 | P1 | `packages/core/src/credentials/index.ts` 的 `resolveCredentials()` 只解析 helper/store;CLI/server 错误信息要求设置 `DEEPSEEK_API_KEY` | 仅设置该环境变量时,生产 resolver 不读取它;CLI onboarding 虽检查变量,后续仍取不到凭证。统一环境变量、Keychain、文件、helper 的优先级与来源诊断,并测试全部入口。 | +| F5 | P1 | CLI `/model` 在 `apps/cli/src/commands.ts:269` 仅允许 chat/reasoner;desktop `slash-commands.ts:27` 与 `Repl.tsx:139` 重复两项列表 | 无法从正常交互选择 Kimi/GLM,甚至 core 已声明的两个 v4 ID 也不在列表。后端提供模型目录,所有客户端消费同一 DTO。 | +| F6 | P1 | `default-runtime.ts:44`、CLI repl/headless 均直接 new DeepSeekProvider;`Provider` 只有 name/runTurn | 自定义地址并不构成多协议、多模型支持。抽出统一 resolver、协议 adapter 与 provider capability;Anthropic Messages 和 OpenAI Chat Completions 分别处理。 | +| F7 | P1 | `runtime-executor.ts:99`、CLI repl/headless 使用 DeepSeek `EFFORT_PARAMS`;`model-metadata.ts` 未知模型默认 128K | effort 被转换为固定输出预算/温度,无法表达厂商 thinking/effort 差异;未知上下文可能错误触发压缩。按 provider/model 解析上下文、输出上限和可用参数;未知能力显式诊断。 | +| F8 | P1 | `deepseek.ts` 转换 assistant history 时明确丢弃 thinking;流请求固定发送 temperature、stream_options | 目前不能保证其他模型需要的推理状态回传、参数支持与工具流格式。保留适配器私有 continuation 数据,并用逐型号 fixture/live contract 验证;不把原始思考默认暴露到 UI。 | +| F9 | P2 | `pricing.ts:estimateCost()` 对未知模型回退 chat 单价;desktop/CLI 消费该估算 | 新模型显示错误费用。未知价格显示“不可估算”;按 provider/model/currency/生效日期记录价格与缓存、reasoning 的计费关系。 | +| F10 | P2 | CLI repl/headless 自行装配 provider/tools/hooks/MCP;server 另有 `composeRuntime()` | RuntimeHost 安全基础已共享,但 CLI 尚未按旧计划成为协议薄客户端;摘要策略、插件、模型目录仍可能分叉。先提取共同 composition,保持 CLI 输出兼容,再逐步迁移协议。 | +| F11 | P2 | protocol `TurnSnapshot` 无 engine/provider/有效参数快照;模型主要藏在消息 payload/legacy metadata | 跨模型恢复时难以确认实际执行来源,SDK session 也无法直接映射。新增兼容的执行描述和引擎私有 session 引用;跨引擎采用显式 fork/import。 | + +F8 是多模型适配缺口,不声称所有 Kimi/GLM 型号都要求同一种 thinking 回传。应由选定型号的官方接口契约与实际请求决定。 + +已有能力应保留:RuntimeHost 默认策略、工具 dispatcher、项目 trust/hook 审核、后端持有凭证、桌面 sidecar、协议审批/中断、legacy 双读、review apply/revert、worktree 保留语义及 release gate。旧计划中的原始 P0 列表是历史审查,不能直接当成最新 main 仍有相同漏洞。 + +## 4. 官方依据与支持边界 + +以下资料于审查日读取,端点与型号仍须随实现版本验证: + +- [Claude Agent SDK overview](https://platform.claude.com/docs/en/agent-sdk/overview):SDK 可嵌入 Claude Code 的 loop、工具、上下文与会话能力。 +- [Anthropic gateway 支持边界](https://code.claude.com/docs/en/llm-gateway):Anthropic 不支持通过网关将 Claude Code 路由至非 Claude 模型。 +- [Kimi 官方 Claude Code 指南](https://platform.kimi.com/docs/guide/claude-code-kimi):提供 Anthropic 兼容端点 `https://api.moonshot.cn/anthropic` 与环境变量配置。 +- [智谱官方 Claude Code 指南](https://docs.bigmodel.cn/cn/coding-plan/tool/claude):提供 `https://open.bigmodel.cn/api/anthropic`;Coding Plan 与标准 API 凭证须按产品区分。 +- [SDK permissions](https://platform.claude.com/docs/en/agent-sdk/permissions):部分调用可能在 `canUseTool` 前获批,全面门禁需要覆盖每次调用的 hook;`allowedTools` 不是工具白名单。 + +厂商提供 Claude Code 指南,不等于已证明 SDK 嵌入、桌面分发、DeepCode 权限映射和全部功能兼容。版本配对、SDK/运行文件的分发条件、所选套餐对应用的可用性,应在 spike 中核实并记录;不将订阅凭证冒用为其他产品 API Key。 + +## 5. 目标架构 + +```text +CLI / desktop / VS Code / LSP + ↓ +app-server / 共享执行服务 + 配置来源、模型目录、Thread/Turn/Item、审批、审计、恢复 + ↓ +AgentEngine(每个 turn 只有一个执行 owner) + ├─ native:现有 RuntimeHost + agent loop + provider adapters + └─ claude-sdk:Claude Code loop + SDK event/session adapter + ↓ +统一强制策略 + 受控工具执行边界 + OS sandbox / 进程监督 +``` + +将 engine、provider、protocol、model、credentialRef 分开。本次配置采用 `engine: native|claude-sdk`、`provider: deepseek|kimi|glm`;profile 使用 `baseURL` / `anthropicURL` 分开表达协议,以 `apiKeyEnv` 引用密钥。自定义模型 ID 归属显式 provider;没有新增 `custom` 协议适配器。密钥只由后端解析。国内/国际端点、Coding Plan/标准 API 建不同 profile,避免自动猜测。 + +原生 Provider 增加能力描述:contextWindow/maxOutput、tool calling、parallel tools、stream usage、图片输入、thinking/effort、continuation 格式、计费定义。UI 只展示公共能力;厂商私有参数留在 adapter。模型目录优先版本化配置与已验证 preset,远端发现仅做补充。 + +不要让原生 loop 调用 SDK loop,也不要让 SDK 工具绕过现有策略。第一版 SDK spike 优先禁用内置可执行工具,通过受控 MCP 暴露现有工具,并验证工具集合、参数、错误与所有调用路径。这样可以保留快照和 review 语义,但工具别名和质量成本也必须实测。若改用 SDK 内置工具,必须先完成每次调用策略 hook、路径/命令约束、外部沙箱、快照和子代理继承的完整验收,不能只接 `canUseTool`。 + +SDK 自有会话作为引擎私有事实源,DeepCode 保存稳定映射和 UI 事件投影;不伪造完整原生可恢复 history。引擎间切换保留旧数据,并以摘要+上下文建立新会话,向用户明确标记 fork/import。原始 reasoning/签名等数据根据协议需要存放在受保护的引擎记录,不默认进入公共 DTO、日志或导出。 + +## 6. 实施顺序与验收 + +| PR | 工作范围 | 必须通过的验收 | +| --- | ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | 修复 F1–F4:结束原因、摘要模型、取消、凭证解析 | 输出截断/循环预算不伪装成功;摘要按当前 profile 路由;取消摘要后不提交 history;环境变量-only 与 helper/store 优先级覆盖 CLI/server。 | +| 2 | profile/credentialRef、统一模型目录与 provider resolver;修复 F5–F9 | 同一配置在 CLI/headless/desktop/editor 解析一致;未知模型不伪造价格或能力;不可信项目不能改 endpoint/credentialRef/engine 或继承敏感环境。 | +| 3 | native Kimi/GLM adapters 与 fixture contract | 按所选协议完成流文本、多工具、错误、429/5xx、半截 JSON、usage、thinking 多轮及 abort;显式 opt-in 小额 live smoke。保留 DeepSeek 回归。 | +| 4 | AgentEngine 接口与 Claude SDK 有界 spike | 固定 SDK/Claude 版本;Kimi、GLM 各验证一种型号:读→编辑→测试→审批拒绝→中断→重启恢复;保存脱敏证据。未达标保持 experimental。 | +| 5 | SDK 权限/沙箱、事件投影、引擎会话映射 | 计划模式无写入;symlink/路径逃逸/命令注入/恶意 MCP/子代理不可绕过;断连解除审批;取消后无延迟写与孤儿进程;review apply/revert 可用或明确 capability 禁用。 | +| 6 | 客户端统一与桌面分发 | CLI 既有参数/JSONL golden 不回退;模型与 effort 来自目录;renderer 无密钥;无系统 Node/Claude 的安装机可启动;签名/公证与升级回滚通过后再发布。 | + +PR 1–3 可在 SDK 决策前独立交付。PR 4 通过后才扩大 SDK 集成;不以“成功回答一句话”作为切换默认引擎的依据。新增 app-server 能力需要协商,并保留旧客户端可识别的状态投影。 + +SDK 默认化条件:连续工具与长任务评测至少不低于 native 基线;审批、取消、恢复、沙箱与打包门禁全部通过;没有重复执行;明确支持的版本和型号组合。比较同一代码快照、任务、测试判据与预算,记录成功率、人工介入、首次工具延迟、总耗时和真实用量,不凭模型名称评判。 + +## 7. 迁移与回滚 + +旧 `model/baseURL` 与凭证映射为 legacy DeepSeek profile;旧文件继续只读,不删除 Keychain 项,不写入用户 `~/.claude` 配置。新增字段纳入 schema、来源诊断、trust gate 与脱敏导出,尤其防止项目 endpoint 获得全局 key。 + +引擎选择按 thread 固定并记录实际 engine/provider/model;中途改引擎建立新 thread。每个 turn 记录请求参数与解析后的有效参数,拒绝重复 turn id 执行;重启后的未知执行结果标为 interrupted/unknown,而非自动重放写工具。 + +回滚设置 `engine=native` 并关闭 SDK capability;保留 SDK 私有会话与映射但不再续写。新格式采用版本检测与双读单写,不要求降级客户端理解 SDK 会话。现有 release 包体/冷启动预算若因 SDK 增大,应提交实测差异与独立引擎包方案,不能静默放宽。 + +## 8. 复审后实施决定与结果 + +复审再次沿 CLI → RuntimeHost、app-server → RuntimeHost、provider/摘要、SDK → MCP → dispatcher 链路检查,以可验证边界缩小首批落地范围。已实现内容: + +| 范围 | 实施结果 | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| F1 结束原因 | native 保留 max_tokens/stop_sequence/error;server 将预算耗尽、blocked 映射为 failed 并附原因;headless 输出预算耗尽退出 4;SDK 保留 result.stop_reason。 | +| F2–F3 摘要与取消 | 摘要默认继承主模型;signal 贯穿请求、命令/HTTP hook 与提交前检查;CLI Ctrl+C 取消当前 turn。未响应取消的摘要不会替换 history。 | +| F4 凭证 | helper → 环境变量 → legacy store;Kimi/GLM 只读取独立环境变量,绝不回退 DeepSeek 密钥或 URL;没有新增密钥文件迁移。 | +| F5–F8 多模型 | 同一 ConfiguredProvider 服务 CLI/server;版本化目录和自定义模型 ID;Kimi/GLM 不发送 DeepSeek temperature;推理 continuation 仅回传同 provider/model;未完整结束的流不会伪装成功。配置支持 contextWindow/maxOutput。 | +| F9 费用 | 未知价格显示不可估算;CLI 混合模型累计用量不按当前单一模型重算;desktop 逐 turn 累加已知价格,遇未知价格标记不可估算。 | +| SDK 引擎 | 锁定 @anthropic-ai/claude-agent-sdk 0.3.310,显式 engine=claude-sdk;真实 SDK 进程拥有 loop;禁用内置工具、自动 settings/plugin 加载与环境凭证继承,SDK 只连接受控 DeepCode MCP 工具桥。 | +| 权限与恢复 | SDK 工具通过统一 dispatcher、sandbox、审批、快照和 ledger;串行执行并在退出前 drain;引擎绑定和 SDK session 映射独立保存,拒绝原地跨引擎/跨模型恢复;文本及工具事件作为 UI 投影。 | +| 复审追加的边界修复 | 直接文件工具检查 workspace/额外可写目录及 symlink 真实路径,read-only 不能被 bypassPermissions 覆盖;auto 分类器错误、缺失或截断不能 fallback allow。 | + +实现边界与后续顺序: + +1. **先真实厂商验收**:本机没有 Kimi/GLM 密钥。已提供 opt-in native live smoke;SDK 已跑真实进程 + 本地 Messages fixture 的写入、审批与重启恢复,不能替代真实 Kimi/GLM 任务矩阵。Anthropic 不保证非 Claude 模型,保持 experimental。 +2. **再做 capability 与会话统一**:当前目录为共享纯数据模块,尚未新增协议模型发现接口;桌面自定义 profile 的上下文/预算标签仍是 preset 显示。native app-server 尚未启用自动压缩,不能直接套用 CLI 的 history 截断,否则损坏基于消息偏移的 durable projection。后续需独立 context checkpoint 与执行参数 DTO。 +3. **再扩展 SDK 工具**:首版桥接 Read/Write/Edit/Bash/Grep/Glob/WebFetch/WebSearch/AskUserQuestion/SubmitReviewFinding/RestoreReviewAction。Task、持久 shell、第三方 MCP/plugin 工具、agent/mcp_tool/prompt hooks 暂不支持;SDK 自动压缩自有上下文,CLI /compact 与 conversation rewind 明确拒绝,/clear 新建 SDK 会话。原生引擎保留这些既有功能。 +4. **最后分发与默认化**:SDK 是可选依赖,桌面/VS Code release bundle 将其 external,不将 SDK 原生可执行文件偷偷塞入现有包。独立安装、签名/公证、无系统 Node/Claude 冷启动以及跨平台包体测量通过前,不将 SDK 设为默认。当前发布路径默认 native。 + +路径检查使用 realpath 预检,未声称消除并发 symlink 替换竞态;shell 仍依赖既有 OS sandbox。原生历史保留现有 thinking 行为;SDK 私有完整历史不导入原生 loop。SDK 文件默认 0600、目录 0700,不修改用户 ~/.claude。 + +2026-10-25 提交前验证(macOS arm64,Node 22.23.4 / pnpm 9.12.0): + +| 检查 | 结果 | +| -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| 冻结依赖安装、typecheck、lint、format:check、build、docs:check | 通过 | +| 完整 pnpm test | 1,754 passed,23 skipped(包含平台限定与 opt-in 用例) | +| Claude SDK 本地实际进程测试 | Kimi、GLM 两种配置共 2 项通过;模拟 Messages 端点,真实工具执行与重启恢复;无厂商请求 | +| 桌面 Playwright | 7 项通过;初次缺少 Chromium,安装测试依赖后通过 | +| cargo test --locked | 30 项通过 | +| release:check | status=ok;VS Code app-server 700,692 bytes(上限 786,432),VSIX 216,702 bytes(上限 262,144);权限/架构扫描通过 | +| 桌面前端、sidecar 与 Node 22 资源构建 | 通过;desktop sidecar 700,736 bytes;仅本地 ad-hoc 签名运行文件,无发行签名/公证 | +| 额外 cargo fmt --check | 未通过:基线未改动的 commands.rs、credentials.rs、file_preview.rs、settings.rs、voice.rs存在格式差异;本次不混入无关 Rust 格式修改 | + +新增测试覆盖:环境/helper/store 优先级及取消、跨 provider 密钥与 reasoning 隔离、未信任 profile、半截流/工具批次、预算耗尽失败投影、自定义模型限制、路径/denyWrite/symlink、hook 子进程取消、SDK 审批拒绝/计划模式/无人值守终止、取消 pending approval、旧 native 历史保护、SDK 恢复及原生回滚拒绝混写。CLI 新增模型/预算、混合费用和 SDK /clear 行为也有回归。 + +创建 PR 时未完成的外部验收:真实 Kimi/GLM 请求、Linux bwrap/netns、真实长任务质量对比、发行签名/公证安装包。后续 CI 结果见第 10 节。没有凭证时 live 测试跳过而非报告通过;上述本地通过不等于生产兼容认证。 + +## 9. 提交前再次复审与修复 + +再次以最新 `origin/main@cedeb85` 核对完整变更,沿正常执行、权限拒绝、取消和恢复四条路径复审。以下问题均在本次提交中修复并加入回归测试: + +| 级别 | 问题与修复 | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| P1 | SDK 快照序号每轮从零开始,ledger 可能指向修改后快照或不存在的编号。现在从持久清单最大编号继续,只记录真实修改前快照;补齐读取来源,未启用快照时不伪造回滚位置。native 同样按最大编号递增。 | +| P1 | 审批等待期间路径变化可能绕过此前检查。native 与 SDK 在审批后及实际执行前再次检查真实路径,并拒绝 workspace 模式下的硬链接写入;review 恢复检查实际目标的 sandbox 和文件 contract。 | +| P1 | AskUserQuestion 的宿主回调不返回时,取消可能无法完成。现在取消立即解除等待;CLI slash 命令、初始化摘要和交互问题传递 signal,取消后不继续提交摘要或后续审计写入。 | +| P1 | SDK 丢失 binding 时可能丢弃旧上下文重新开始。现在保留已有历史并拒绝恢复,校验绑定版本;CLI 阻止有历史的 SDK 会话原地切换模型。SDK 的晚到传输错误也不能覆盖成成功。 | +| P1 | hook/helper shell 退出后仍可能留下后台子进程写文件。现在结束时清理进程组,取消/超时同时关闭管道;空摘要也不得替换原历史。 | +| P2 | SDK 初始化或关闭异常可能跳过工具排空、监听器解绑与临时目录清理。现在这些异常均经过收尾路径,报告失败;显式禁用的 hooks 不再错误阻止 SDK 启动。 | + +补充兼容性:CLI 识别只配置 `DEEPSEEK_AUTH_TOKEN` 的启动方式,模型切换后的后台请求使用当前模型预算;SDK 工具桥遵守调用方的 spill 配置。snapshot 编号、修改前回滚、硬链接、审批后 symlink 替换、文件恢复限制、交互取消、孤儿进程、空摘要、SDK 绑定丢失/版本错误及清理失败均有定向测试。 + +native 默认和显式回滚路径不变:`engine: "native"` 加新会话;已有 SDK 记录保留且不混写。额外 Rust 格式检查的失败来自未修改的基线文件,未混入格式修复。真实厂商与发行安装包仍按第 8 节作为外部验收项,不能由本地 fixture 测试代替。 + +## 10. 2026-10-26 CI 验证与持续门禁 + +核对提交 `6a102c7` 的 [CI 运行记录](https://github.com/oratis/deepcode/actions/runs/40199497883),5 项检查全部通过:Ubuntu/macOS 的类型、lint、格式、测试与发布门禁,以及桌面端到端、Rust、文档检查。Ubuntu 测试为 1,766 passed / 11 skipped,macOS 为 1,754 passed / 23 skipped。Ubuntu 的 bwrap 集成 11 项、netns 集成 2 项实际执行并通过,Linux sandbox 不再列为待验收。 + +首次 CI 的 SDK 实际进程 smoke 属于 opt-in,尚未执行;本次将其加入 Ubuntu/macOS 矩阵的独立必跑步骤。两种模型配置连接本地 Messages fixture,使用假密钥,验证安装的 SDK 可执行文件、受控工具写入与会话恢复,不依赖厂商密钥,也不消耗厂商额度。该步骤失败会阻止 CI 通过;最新提交的结果记录在 [PR #296](https://github.com/oratis/deepcode/pull/296)。 + +仍待验收:真实 Kimi/GLM 端点和套餐、多轮长任务质量与成本、SDK 独立安装及发行签名/公证包、无系统 Node/Claude 的冷启动和升级回滚。SDK 保持实验功能,native 默认不变。 + +## 11. 独立 agent 审查与发布前修复 + +独立 agent 对 `5df23ab` 进行只读审查并发布 [COMMENT review](https://github.com/oratis/deepcode/pull/296#pullrequestreview-5644341351),三项发现均已复现并修复: + +- **P1 取消后启动写入**:native/SDK 在最后一次异步路径核验后再次检查 signal;Write/Edit/NotebookEdit 在异步准备后、实际写入前检查取消,并向文件 I/O 传递 signal。回归测试在最终 stat、mkdir 和原文件读取完成时注入取消,确认不启动 handler 或不覆盖原文件。已经提交到操作系统的写入不承诺事务回滚。 +- **P2 额外目录丢失**:共享目录合并器按有效 sandbox mode 处理默认和 mode-only 配置,CLI/server 的额外目录都进入 allowWrite;native 与 SDK 测试同时验证 read-only/denyWrite 优先。 +- **P2 SDK 会话分叉丢失来源**:CLI 和 canonical protocol store 在复制前统一验证源会话。SDK、未知版本或丢失 binding 但仍有 SDK 目录的会话拒绝分叉;原生及旧原生会话继续可用。失败不会创建目标会话,也不修改源历史。 + +发布准备同步 `0.4.0` 的六处版本号和 CHANGELOG,并把 SDK 实际进程 smoke 加入标签发布验证。按既有发布流程,缺少 Apple/npm 凭证时只发布 VSIX 与源码;SDK 可选运行文件仍不附带到 VSIX,native 仍是默认。合并与发布以独立复审和最新 CI 通过为条件;最终证据记录在 PR。 + +独立复审确认三项均修复,额外运行 core 49 项、CLI 恢复 9 项、server store 8 项全部通过。修复后的本地全量测试为 1,778 passed / 23 skipped;SDK 实际进程 2 项、桌面 E2E 7 项、Rust 30 项通过。冻结安装、类型、lint、格式、构建、文档与发布检查通过;VS Code app-server 701,845 bytes、VSIX 216,965 bytes,均在既有预算内。 diff --git a/docs/multi-model.md b/docs/multi-model.md new file mode 100644 index 00000000..43f3d01e --- /dev/null +++ b/docs/multi-model.md @@ -0,0 +1,91 @@ +# 多模型与可选 Claude Agent SDK 引擎 + +DeepCode 默认使用原生引擎。此分支新增 Kimi、GLM 的 OpenAI Chat Completions 适配,并提供显式启用的 Claude Agent SDK 实验引擎。预设是 `kimi-k2.5`、`glm-4.7`;真实厂商验收尚未完成。架构决定和验收范围见 [ADR 0002](adr/0002-multi-model-agent-engine.md)。 + +## 原生引擎配置 + +密钥放在后端进程的环境中,不写进项目配置或 renderer: + +```bash +export MOONSHOT_API_KEY="" +export GLM_API_KEY="" +deepcode --model kimi-k2.5 +deepcode --model glm-4.7 -p "概括这个项目" --mode plan +``` + +`/model kimi-k2.5`、`/model glm-4.7` 支持在原生会话内切换。DeepSeek 继续支持 `DEEPSEEK_API_KEY`、`DEEPSEEK_AUTH_TOKEN` 和既有 Keychain/凭证文件。DeepSeek 的解析优先级为成功的 `apiKeyHelper` → 环境变量 → 已存凭证;Kimi/GLM 不会读取 legacy helper/store。缺少对应密钥时明确失败。 + +在用户 `~/.deepcode/settings.json` 中设置默认项,或使用 `deepcode --settings /absolute/path/settings.json`: + +```json +{ + "engine": "native", + "provider": "kimi", + "model": "kimi-k2.5", + "providers": { + "kimi": { + "apiKeyEnv": "MOONSHOT_API_KEY", + "baseURL": "https://api.moonshot.cn/v1" + }, + "glm": { + "apiKeyEnv": "GLM_API_KEY", + "baseURL": "https://open.bigmodel.cn/api/paas/v4" + } + } +} +``` + +已知 `kimi-`/`glm-`/`deepseek-` 前缀选择相应 provider;其他自定义模型 ID 使用 `provider` 指定的适配器。`providers..model` 是该 provider 的默认模型,顶层 `model` 优先。自定义 profile 可提供正整数 `contextWindow` 与 `maxOutput`;未知模型上下文按保守的 32,000 预算处理,输出按 8,192 限制,均不是厂商能力声明。 + +本次预设对 Kimi 的输出最多使用 16,384 tokens、GLM 最多 32,768 tokens,是应用的保守限制。`effort` 控制输出预算,不宣称等同于厂商推理强度。Kimi/GLM 不发送 DeepSeek 的 temperature。未知价格显示“不可估算”;费用以厂商账单为准。 + +不同区域、国际端点或 Coding Plan 必须显式配置该产品对应的 URL 和密钥环境变量名,不能混用套餐凭证。OpenAI 与 Anthropic 端点分别配置,不自动改写 URL。端点须 HTTPS;仅本机 loopback 测试可 HTTP。不可信项目的 `engine/provider/providers/model/baseURL` 会被 trust gate 移除,保留用户级配置。 + +桌面、VS Code/LSP 的 app-server 使用同一后端 resolver。桌面模型菜单读取共享预设,也允许 `/model` 输入自定义 ID;自定义 profile 的 UI 上下文标签暂不反映后端覆盖值。桌面进程必须实际继承密钥环境变量;本次没有新增前端密钥存储表单。 + +## Claude Agent SDK 实验引擎 + +源码 workspace 的可选依赖锁定为 `@anthropic-ai/claude-agent-sdk@0.3.310`。正常 `pnpm install --frozen-lockfile` 会安装可用平台的运行文件。没有可选依赖时 native 仍可运行;显式选择 SDK 会给出安装或回滚提示。当前桌面/VS Code 发布包不附带 SDK 运行文件。 + +用户配置示例: + +```json +{ + "engine": "claude-sdk", + "provider": "kimi", + "model": "kimi-k2.5", + "providers": { + "kimi": { + "apiKeyEnv": "MOONSHOT_API_KEY", + "anthropicURL": "https://api.moonshot.cn/anthropic" + }, + "glm": { + "apiKeyEnv": "GLM_API_KEY", + "anthropicURL": "https://open.bigmodel.cn/api/anthropic" + } + } +} +``` + +GLM 改为 `provider: "glm"` 与 `model: "glm-4.7"`。profile 配了自定义 `baseURL` 时,SDK 必须同时显式配置 `anthropicURL`,防止密钥被发送到推测的地址。DeepSeek 暂只支持 native。 + +SDK 独占 agent loop;DeepCode 的内置文件/命令工具经受控 MCP 桥进入同一审批与 sandbox。SDK 内置工具、自动 settings/plugin 加载均禁用,运行环境只注入选中厂商的凭证。当前桥接范围为 Read、Write、Edit、Bash、Grep、Glob、WebFetch、WebSearch、AskUserQuestion 及 review 工具;第三方 MCP/plugin 工具、Task、持久 shell 和 agent/mcp_tool/prompt hooks 尚未支持。SDK 检测到启用中的不支持 hook 类型时会拒绝启动,避免静默跳过。command hooks 和凭证 helper 必须在调用内完成工作;shell 结束时会清理其后台子进程,持久任务应通过原生任务系统运行。 + +SDK 自行管理上下文。CLI `/compact` 和会话 rewind 会说明不可用,文件 rewind 保留;`/clear` 建立新 SDK 会话。不要把 DeepCode 的 UI 消息投影当成 SDK 的完整历史。SDK session 与 engine 绑定保存在 `sessions//`,不修改 `~/.claude`。 + +回滚时设置 `"engine": "native"` 并新建会话。旧 SDK 记录保留;同一 session 不能直接切换引擎,SDK session 不能中途改 provider/model/endpoint;CLI 更换模型前须先 `/clear`。绑定文件丢失或版本不支持时会拒绝恢复,原历史仍保留。该限制避免重复执行工具和错误恢复。当前不提供跨引擎自动历史导入。SDK 会话暂不支持 CLI `--fork-session` 或协议 `thread/fork`;复制前会拒绝并保留源会话,避免将 UI 投影当成完整原生历史。 + +## 验证 + +```bash +pnpm typecheck +pnpm test +# 安装的真实 SDK 进程 + 本地模拟 Messages 服务;不调用厂商 +DEEPCODE_SDK_SMOKE=1 pnpm --filter @deepcode/app-server exec vitest run src/engines/claude-sdk.smoke.test.ts +# 显式联网验证;只对配置了密钥的 provider 执行,会消耗对应账户额度 +DEEPCODE_LIVE_TESTS=1 pnpm --filter @deepcode/core exec vitest run src/providers/multi.live.test.ts +``` + +截断和循环预算耗尽不再报告成功:headless 返回退出码 4;server 返回 failed 和原因。取消覆盖模型请求、审批、用户问答、摘要、命令/HTTP hooks 和受控工具队列。文件工具拒绝 workspace 之外(含 symlink)的写入,除非显式增加可写目录或使用 danger-full-access;read-only 仍然禁止写入。默认或显式 workspace-write 模式均支持 `permissions.additionalDirectories`(`/add-dir`),但 read-only 和 denyWrite 仍优先。workspace 模式拒绝硬链接文件写入;审批后会重新检查路径,review 恢复也遵守文件限制。SDK 快照编号跨轮次延续,回滚记录指向实际修改前快照;关闭快照时不会提供虚构的回滚引用。 + +Ubuntu/macOS CI 另行运行上述 SDK 实际进程 smoke,使用本地模拟端点和假密钥;Linux CI 也执行 bwrap/netns 的真实内核集成测试。当前 SDK 仍是实验功能:协议、权限及跨平台测试不能证明非 Claude 厂商完全兼容,也不代表签名安装包和真实长任务验收已完成。具体结果与剩余工作记录在 ADR。 diff --git a/docs/quickstart.md b/docs/quickstart.md index e58fbe7e..296d2a6e 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -1,6 +1,6 @@ # DeepCode Quickstart -DeepCode is a coding agent driven by **DeepSeek**. It ships as a **CLI** +DeepCode is a coding agent with **DeepSeek** as the default provider. Kimi/GLM support and the optional Claude Agent SDK engine are described in the [multi-model guide](multi-model.md). It ships as a **CLI** (`deepcode`) and a **macOS desktop app**, with VS Code/LSP integrations under active development. The clients reuse `@deepcode/core`, but some runtime, permission, and recovery behavior is still being unified; see the @@ -50,7 +50,7 @@ deepcode --model deepseek-reasoner --effort high # deeper reasoning `-p`/`--print` runs a single prompt and exits. Combine with `--output-format json` for machine-readable output. Exit codes: `0` ok · `1` generic · `2` bad-input · -`3` api/auth · `4` max-turns · `5` aborted · `6` blocked (see below). +`3` api/auth · `4` output/turn budget exhausted · `5` aborted · `6` blocked (see below). ```bash deepcode -p "summarize the architecture" --output-format json diff --git a/packages/core/package.json b/packages/core/package.json index 14b4192a..6c79ccfd 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -93,7 +93,11 @@ "import": "./dist/skills/index.js" }, "./skills/*": "./skills/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./dist/providers/catalog.js": { + "types": "./dist/providers/catalog.d.ts", + "import": "./dist/providers/catalog.js" + } }, "files": [ "dist/", diff --git a/packages/core/schemas/settings.schema.json b/packages/core/schemas/settings.schema.json index 35b073eb..97fa29bb 100644 --- a/packages/core/schemas/settings.schema.json +++ b/packages/core/schemas/settings.schema.json @@ -6,10 +6,116 @@ "type": "object", "additionalProperties": true, "properties": { + "provider": { + "type": "string", + "enum": ["deepseek", "kimi", "glm"] + }, + "engine": { + "type": "string", + "enum": ["native", "claude-sdk"], + "default": "native", + "description": "Claude SDK is experimental; start a new session when changing engines." + }, + "providers": { + "type": "object", + "additionalProperties": false, + "properties": { + "deepseek": { + "type": "object", + "additionalProperties": false, + "properties": { + "baseURL": { + "type": "string", + "format": "uri" + }, + "apiKeyEnv": { + "type": "string", + "pattern": "^[A-Z_][A-Z0-9_]*$" + }, + "model": { + "type": "string", + "minLength": 1 + }, + "contextWindow": { + "type": "integer", + "minimum": 1024 + }, + "maxOutput": { + "type": "integer", + "minimum": 1 + }, + "anthropicURL": { + "type": "string", + "format": "uri" + } + } + }, + "kimi": { + "type": "object", + "additionalProperties": false, + "properties": { + "baseURL": { + "type": "string", + "format": "uri" + }, + "apiKeyEnv": { + "type": "string", + "pattern": "^[A-Z_][A-Z0-9_]*$" + }, + "model": { + "type": "string", + "minLength": 1 + }, + "contextWindow": { + "type": "integer", + "minimum": 1024 + }, + "maxOutput": { + "type": "integer", + "minimum": 1 + }, + "anthropicURL": { + "type": "string", + "format": "uri" + } + } + }, + "glm": { + "type": "object", + "additionalProperties": false, + "properties": { + "baseURL": { + "type": "string", + "format": "uri" + }, + "apiKeyEnv": { + "type": "string", + "pattern": "^[A-Z_][A-Z0-9_]*$" + }, + "model": { + "type": "string", + "minLength": 1 + }, + "contextWindow": { + "type": "integer", + "minimum": 1024 + }, + "maxOutput": { + "type": "integer", + "minimum": 1 + }, + "anthropicURL": { + "type": "string", + "format": "uri" + } + } + } + } + }, "model": { "type": "string", - "enum": ["deepseek-chat", "deepseek-reasoner", "deepseek-v4-flash", "deepseek-v4-pro"], - "description": "Default model. deepseek-chat = fast general; deepseek-reasoner = multi-step reasoning." + "minLength": 1, + "description": "Model ID; known prefixes select DeepSeek, Kimi or GLM. Custom IDs use provider." }, "baseURL": { "type": "string", diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index a2249c39..99ee8c57 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -20,6 +20,7 @@ import type { HookDispatcher } from './hooks/index.js'; import type { Mode } from './types.js'; import type { Provider } from './providers/types.js'; import { resolveRuntimePolicy } from './runtime/policy.js'; +import { fileWriteDenial } from './sandbox/file-policy.js'; import { applySpillPolicy, type SpillStore } from './spill/index.js'; import { ShellRegistry } from './shell/registry.js'; // NOTE: reminders + sessions are lazy-loaded inside the loop so a browser @@ -194,7 +195,8 @@ export interface RunAgentResult { * Reason the loop terminated. `blocked` means an unattended run hit a call * needing approval while configured with `onApprovalRequired: 'abort'`. */ - stopReason: 'end_turn' | 'max_turns' | 'aborted' | 'error' | 'blocked'; + stopReason: + 'end_turn' | 'stop_sequence' | 'max_tokens' | 'max_turns' | 'aborted' | 'error' | 'blocked'; /** Mode-control signals flipped by tools during this run (M3c-rest). */ modeSignal?: { exitPlanMode?: boolean; enterPlanMode?: boolean }; } @@ -275,7 +277,11 @@ async function runAgentInner(opts: RunAgentOptions): Promise { const runtimePolicy = resolveRuntimePolicy(opts); const allowedToolNames = opts.allowedTools ? new Set(opts.allowedTools) : undefined; let history: StoredMessage[] = [...(opts.history ?? [])]; - let snapshotSeq = (await opts.session?.manager.snapshots(opts.session.id))?.length ?? 0; + let snapshotSeq = + (await opts.session?.manager.snapshots(opts.session.id))?.reduce( + (max, snapshot) => Math.max(max, snapshot.seq), + 0, + ) ?? 0; // Append the user message first (if provided). When systemReminders is // enabled (default), prepend a block ahead of the user @@ -482,6 +488,7 @@ async function runAgentInner(opts: RunAgentOptions): Promise { if (opts.hooks) { try { await opts.hooks.dispatch({ + signal: opts.signal, event: 'SubagentStop', cwd: opts.cwd, triggeredAt: new Date().toISOString(), @@ -589,6 +596,7 @@ async function runAgentInner(opts: RunAgentOptions): Promise { if (!opts.hooks || depth !== 0) return; try { await opts.hooks.dispatch({ + signal: opts.signal, event: 'Stop', cwd: opts.cwd, triggeredAt: new Date().toISOString(), @@ -601,6 +609,7 @@ async function runAgentInner(opts: RunAgentOptions): Promise { const finish = async (stopReason: RunAgentResult['stopReason']): Promise => { await fireStop(stopReason); + if (opts.signal?.aborted) stopReason = 'aborted'; const message = [...history].reverse().find((candidate) => candidate.role === 'assistant'); opts.onEvent?.({ type: 'turn_complete', stopReason, message }); return { history, turnsUsed, usage: totalUsage, stopReason, modeSignal }; @@ -640,6 +649,7 @@ async function runAgentInner(opts: RunAgentOptions): Promise { return finish('error'); } + if (opts.signal?.aborted) return finish('aborted'); totalUsage.inputTokens += result.usage.inputTokens; totalUsage.outputTokens += result.usage.outputTokens; totalUsage.reasoningTokens += result.usage.reasoningTokens; @@ -678,7 +688,7 @@ async function runAgentInner(opts: RunAgentOptions): Promise { // If no tool calls, we're done if (result.stopReason !== 'tool_use') { - return finish('end_turn'); + return finish(result.stopReason); } // Execute tool calls and append a single user-role message with tool_result @@ -809,8 +819,13 @@ async function runAgentInner(opts: RunAgentOptions): Promise { hooks: opts.hooks, cwd: opts.cwd, autoMode: opts.autoMode, + sandboxConfig: opts.sandboxConfig, + sandboxDefaultMode: opts.sandboxDefaultMode, autoModeProvider: opts.provider, + autoModeModel: opts.model, + signal: opts.signal, }); + if (opts.signal?.aborted) return finish('aborted'); let allowed = verdict.decision === 'allow'; let blockReason = verdict.reason; if (verdict.decision === 'ask' && opts.unattended) { @@ -893,6 +908,26 @@ async function runAgentInner(opts: RunAgentOptions): Promise { // hook, post-snapshot, event + result. Side-effect-free tools call this // concurrently; mutating tools call it one at a time (see partition below). const execOne = async ({ toolUse, handler }: Ready): Promise => { + if (opts.signal?.aborted) return; + const denial = await fileWriteDenial( + toolUse.name, + toolUse.input, + opts.cwd, + opts.sandboxConfig, + opts.sandboxDefaultMode, + ); + if (opts.signal?.aborted) return; + if (denial) { + const result = { content: `Tool call blocked: ${denial}`, isError: true }; + opts.onEvent?.({ type: 'tool_result', id: toolUse.id, result }); + resultsById.set(toolUse.id, { + type: 'tool_result', + tool_use_id: toolUse.id, + ...result, + is_error: true, + }); + return; + } const isFileMutation = toolUse.name === 'Edit' || toolUse.name === 'Write'; // Remembered so a ledger record can point at the checkpoint taken for // this specific call rather than at "whatever was last captured". @@ -929,10 +964,21 @@ async function runAgentInner(opts: RunAgentOptions): Promise { let tr; try { + opts.signal?.throwIfAborted(); + const denial = await fileWriteDenial( + toolUse.name, + toolUse.input, + opts.cwd, + opts.sandboxConfig, + opts.sandboxDefaultMode, + ); + if (denial) throw new Error(denial); + opts.signal?.throwIfAborted(); tr = await withDeadline(toolUse, handler); } catch (err) { tr = { content: `Error: ${(err as Error).message}`, isError: true }; } + if (opts.signal?.aborted) return; // Every result passes the spill policy on its way to the model, so no // single tool can flood the context regardless of what it returns. @@ -945,6 +991,7 @@ async function runAgentInner(opts: RunAgentOptions): Promise { // PostToolUse hook (M3) — observation only; can inject additionalContext if (opts.hooks) { await opts.hooks.dispatch({ + signal: opts.signal, event: 'PostToolUse', cwd: opts.cwd, triggeredAt: new Date().toISOString(), @@ -957,6 +1004,7 @@ async function runAgentInner(opts: RunAgentOptions): Promise { }); } + if (opts.signal?.aborted) return; if (opts.enableSnapshots !== false && opts.session && isFileMutation && !tr.isError) { const filePath = (toolUse.input as { file_path?: string }).file_path; if (filePath) { @@ -1045,6 +1093,7 @@ async function runAgentInner(opts: RunAgentOptions): Promise { try { if (opts.hooks) { await opts.hooks.dispatch({ + signal: opts.signal, event: 'PreCompact', cwd: opts.cwd, triggeredAt: new Date().toISOString(), @@ -1053,10 +1102,12 @@ async function runAgentInner(opts: RunAgentOptions): Promise { } const compactResult = await compact(history, { provider: opts.provider, - summarizerModel: opts.autoCompact.summarizerModel, + summarizerModel: opts.autoCompact.summarizerModel ?? opts.model, + signal: opts.signal, keepFirstPairs: opts.autoCompact.keepFirstPairs, keepLastMessages: opts.autoCompact.keepLastMessages, }); + if (opts.signal?.aborted) return finish('aborted'); history = compactResult.history; totalUsage.inputTokens += compactResult.usage.inputTokens; totalUsage.outputTokens += compactResult.usage.outputTokens; @@ -1069,6 +1120,7 @@ async function runAgentInner(opts: RunAgentOptions): Promise { }); if (opts.hooks) { await opts.hooks.dispatch({ + signal: opts.signal, event: 'PostCompact', cwd: opts.cwd, triggeredAt: new Date().toISOString(), diff --git a/packages/core/src/auto-mode/index.ts b/packages/core/src/auto-mode/index.ts index 1a829fef..2e6e2e0c 100644 --- a/packages/core/src/auto-mode/index.ts +++ b/packages/core/src/auto-mode/index.ts @@ -21,8 +21,9 @@ export interface ClassifyOpts { config?: AutoModeConfig; /** Provider for the LLM judge. Required unless config decides statically. */ provider?: Provider; - /** Model id for the judge call. Defaults to config.model or deepseek-chat. */ + /** Model id for the judge call. Defaults to config.model or the active provider model. */ model?: string; + signal?: AbortSignal; } /** @@ -55,13 +56,19 @@ export async function classifyAutoMode(opts: ClassifyOpts): Promise // LLM classifier if (!opts.provider) { - return (cfg.fallback ?? 'ask') as AutoVerdict; + return cfg.fallback === 'deny' ? 'deny' : 'ask'; } try { - const verdict = await llmClassify(opts.provider, opts.toolName, opts.toolInput, opts.model); + const verdict = await llmClassify( + opts.provider, + opts.toolName, + opts.toolInput, + cfg.model ?? opts.model, + opts.signal, + ); return verdict; } catch { - return (cfg.fallback ?? 'ask') as AutoVerdict; + return cfg.fallback === 'deny' ? 'deny' : 'ask'; } } @@ -79,20 +86,23 @@ async function llmClassify( toolName: string, toolInput: Record, model?: string, + signal?: AbortSignal, ): Promise { const userMsg = `Tool: ${toolName}\nInput: ${JSON.stringify(toolInput).slice(0, 1500)}`; const result = await provider.runTurn({ - model: model ?? 'deepseek-chat', + signal, + model: model ?? provider.defaultModel ?? 'deepseek-chat', systemPrompt: CLASSIFY_PROMPT, tools: [], messages: [{ role: 'user', content: [{ type: 'text', text: userMsg }] }], maxTokens: 8, temperature: 0, }); + if (signal?.aborted || result.stopReason !== 'end_turn') return 'ask'; const textBlock = result.content.find((c) => c.type === 'text'); if (!textBlock || textBlock.type !== 'text') return 'ask'; const raw = textBlock.text.toLowerCase().trim(); - if (raw.startsWith('allow')) return 'allow'; + if (raw === 'allow') return 'allow'; if (raw.startsWith('deny')) return 'deny'; return 'ask'; } diff --git a/packages/core/src/compaction/index.ts b/packages/core/src/compaction/index.ts index ea0bf08b..fa57b459 100644 --- a/packages/core/src/compaction/index.ts +++ b/packages/core/src/compaction/index.ts @@ -9,7 +9,8 @@ import type { ContentBlock, StoredMessage, TextBlock } from '../types.js'; export interface CompactionOpts { /** Provider to use for the summarization call. */ provider: Provider; - /** Model to use for summarization (default `deepseek-chat` — cheaper). */ + signal?: AbortSignal; + /** Model to use for summarization (defaults to the active provider model). */ summarizerModel?: string; /** Keep the first N user/assistant pairs verbatim (system context anchor). */ keepFirstPairs?: number; @@ -47,6 +48,7 @@ export async function compact( history: StoredMessage[], opts: CompactionOpts, ): Promise { + opts.signal?.throwIfAborted(); const keepFirst = opts.keepFirstPairs ?? DEFAULT_KEEP_FIRST; const keepLast = opts.keepLastMessages ?? DEFAULT_KEEP_LAST; if (history.length <= keepFirst + keepLast + 1) { @@ -64,8 +66,10 @@ export async function compact( const summaryPrompt = buildSummaryPrompt(middle); + opts.signal?.throwIfAborted(); const result = await opts.provider.runTurn({ - model: opts.summarizerModel ?? 'deepseek-chat', + signal: opts.signal, + model: opts.summarizerModel ?? opts.provider.defaultModel ?? 'deepseek-chat', systemPrompt: 'You compress long agent conversations. Output a TERSE summary preserving: ' + '(1) what files were read/modified and key contents; ' + @@ -82,12 +86,16 @@ export async function compact( temperature: 0.2, }); - const summaryText = - result.content - .filter((b): b is TextBlock => b.type === 'text') - .map((b) => b.text) - .join('\n') - .trim() || '[compaction failed — no summary returned]'; + opts.signal?.throwIfAborted(); + if (result.stopReason !== 'end_turn' && result.stopReason !== 'stop_sequence') { + throw new Error(`Compaction did not finish: ${result.stopReason}`); + } + const summaryText = result.content + .filter((b): b is TextBlock => b.type === 'text') + .map((b) => b.text) + .join('\n') + .trim(); + if (!summaryText) throw new Error('Compaction did not return a summary'); const summaryMsg: StoredMessage = { role: 'assistant', diff --git a/packages/core/src/config/schema.test.ts b/packages/core/src/config/schema.test.ts index a84fd806..28674acc 100644 --- a/packages/core/src/config/schema.test.ts +++ b/packages/core/src/config/schema.test.ts @@ -30,10 +30,10 @@ describe('validateSettingsShallow', () => { ).toEqual([]); }); - it('flags unknown model', () => { - const errs = validateSettingsShallow({ model: 'gpt-4' }); + it('flags invalid model', () => { + const errs = validateSettingsShallow({ model: '' }); expect(errs.length).toBe(1); - expect(errs[0]).toMatch(/gpt-4/); + expect(errs[0]).toMatch(/model/); }); it('flags unknown effort tier', () => { @@ -63,7 +63,7 @@ describe('validateSettingsShallow', () => { it('catches multiple errors at once', () => { const errs = validateSettingsShallow({ - model: 'bad', + model: '', effortLevel: 'bad', permissions: { defaultMode: 'bad' }, }); diff --git a/packages/core/src/config/trust-gate.ts b/packages/core/src/config/trust-gate.ts index c28a014d..3256d393 100644 --- a/packages/core/src/config/trust-gate.ts +++ b/packages/core/src/config/trust-gate.ts @@ -12,6 +12,9 @@ export type TrustStatus = 'trusted' | 'plan-only' | 'untrusted'; /** Project/local settings fields that can execute code or widen runtime authority. */ export const TRUST_GATED_FIELDS = [ 'model', + 'provider', + 'engine', + 'providers', 'baseURL', 'forceLoginMethod', 'effortLevel', diff --git a/packages/core/src/config/types.ts b/packages/core/src/config/types.ts index 75caf7a2..9ed66fba 100644 --- a/packages/core/src/config/types.ts +++ b/packages/core/src/config/types.ts @@ -143,6 +143,14 @@ export interface VoiceConfig { export interface DeepCodeSettings { // Identity + engine?: 'native' | 'claude-sdk'; + provider?: import('../providers/catalog.js').ProviderId; + providers?: Partial< + Record< + import('../providers/catalog.js').ProviderId, + import('../providers/catalog.js').ProviderProfile + > + >; model?: string; baseURL?: string; apiKeyHelper?: string; diff --git a/packages/core/src/config/validation.ts b/packages/core/src/config/validation.ts index c93b558b..9bbf6315 100644 --- a/packages/core/src/config/validation.ts +++ b/packages/core/src/config/validation.ts @@ -1,3 +1,5 @@ +import { validModelId } from '../providers/catalog.js'; + /** * Lightweight validation for diagnostics. Kept independent of the static * schema reader so CJS sidecar bundles do not need `import.meta.url`. @@ -5,10 +7,18 @@ export function validateSettingsShallow(settings: Record): string[] { const errors: string[] = []; - const modelEnum = ['deepseek-chat', 'deepseek-reasoner', 'deepseek-v4-flash', 'deepseek-v4-pro']; - if (settings['model'] !== undefined && !modelEnum.includes(settings['model'] as string)) { - errors.push(`settings.model "${settings['model']}" not in ${modelEnum.join(' | ')}`); + if (settings.model !== undefined && !validModelId(settings.model)) { + errors.push('settings.model must be a non-empty model identifier'); } + if ( + settings.provider !== undefined && + !['deepseek', 'kimi', 'glm'].includes(String(settings.provider)) + ) { + errors.push('settings.provider must be deepseek, kimi or glm'); + } + + if (settings.engine !== undefined && !['native', 'claude-sdk'].includes(String(settings.engine))) + errors.push('settings.engine must be native or claude-sdk'); const effortEnum = ['low', 'medium', 'high', 'xhigh', 'max']; if ( diff --git a/packages/core/src/credentials/index.ts b/packages/core/src/credentials/index.ts index 6f79b1ef..421f01dd 100644 --- a/packages/core/src/credentials/index.ts +++ b/packages/core/src/credentials/index.ts @@ -1,3 +1,4 @@ +import { runCommand } from '../hooks/dispatcher.js'; // Credentials — macOS Keychain primary, ~/.deepcode/credentials.json fallback. // Spec: docs/DEVELOPMENT_PLAN.md §3.4 @@ -178,22 +179,45 @@ export class CredentialsStore { export async function resolveCredentials(args: { store: CredentialsStore; apiKeyHelper?: string; + apiKeyEnv?: string; + /** Only the legacy DeepSeek connection may read the unscoped credential store. */ + useStore?: boolean; + env?: NodeJS.ProcessEnv; + signal?: AbortSignal; }): Promise { + args.signal?.throwIfAborted(); + const stored = args.useStore === false ? {} : await args.store.load(); + const env = args.env ?? process.env; + const envKey = env[args.apiKeyEnv ?? 'DEEPSEEK_API_KEY']?.trim(); + const legacyToken = + args.useStore !== false && (!args.apiKeyEnv || args.apiKeyEnv === 'DEEPSEEK_API_KEY') + ? env.DEEPSEEK_AUTH_TOKEN?.trim() + : undefined; + const fallback = envKey + ? { ...stored, apiKey: envKey, authToken: undefined } + : legacyToken + ? { ...stored, apiKey: undefined, authToken: legacyToken } + : stored; if (args.apiKeyHelper) { try { - const { stdout } = await execFileAsync('/bin/sh', ['-c', args.apiKeyHelper], { - timeout: 10_000, + const { stdout, exitCode } = await runCommand({ + command: args.apiKeyHelper, + cwd: process.cwd(), + env: process.env, + timeoutMs: 10_000, + signal: args.signal, }); - const key = stdout.trim(); + args.signal?.throwIfAborted(); + const key = exitCode === 0 ? stdout.trim() : ''; if (key) { - const stored = await args.store.load(); - return { ...stored, apiKey: key }; + return { ...stored, apiKey: key, authToken: undefined }; } } catch { // fall through to stored creds } } - return args.store.load(); + args.signal?.throwIfAborted(); + return fallback; } /** diff --git a/packages/core/src/harness/tool-dispatcher.ts b/packages/core/src/harness/tool-dispatcher.ts index 16d3502b..6757275e 100644 --- a/packages/core/src/harness/tool-dispatcher.ts +++ b/packages/core/src/harness/tool-dispatcher.ts @@ -1,3 +1,4 @@ +import { fileWriteDenial } from '../sandbox/file-policy.js'; // Tool dispatcher — the central gate. Combines mode + permission + hook decision // into a single allow/ask/deny verdict. // Spec: docs/design/sandbox-plan-worktree.md §5.1 @@ -12,12 +13,19 @@ import { } from '../config/permissions.js'; import { evaluateContract, mostRestrictive } from '../config/contract-dispatch.js'; import type { FileContract } from '../config/file-contract.js'; -import type { AutoModeConfig, PermissionRules } from '../config/types.js'; +import type { + AutoModeConfig, + PermissionRules, + SandboxConfig, + SandboxMode, +} from '../config/types.js'; import type { Mode } from '../types.js'; import type { HookDispatcher, HookResult } from '../hooks/index.js'; import type { Provider } from '../providers/types.js'; export interface DispatchRequest { + sandboxConfig?: SandboxConfig; + sandboxDefaultMode?: SandboxMode; tool: string; input: Record; mode: Mode; @@ -30,6 +38,8 @@ export interface DispatchRequest { autoMode?: AutoModeConfig; /** Provider used for the LLM classifier in auto mode. */ autoModeProvider?: Provider; + autoModeModel?: string; + signal?: AbortSignal; } export interface DispatchVerdict { @@ -110,6 +120,14 @@ export async function dispatchToolCall(req: DispatchRequest): Promise + (matchers ?? []).flatMap((matcher) => matcher.hooks.map((handler) => handler.type)), + ), + ), + ]; + } + /** * Merge additional hook matchers into the dispatcher (e.g. from plugins). * Matchers under the same event name are appended in order. @@ -104,12 +116,13 @@ export class HookDispatcher { anyBlocked: false, timings: [], }; - if (this.disabled) return result; + if (this.disabled || ctx.signal?.aborted) return result; const matchers = this.hooks[ctx.event] ?? []; for (const m of matchers) { if (!this.matcherApplies(m, ctx)) continue; for (const handler of m.hooks) { + if (ctx.signal?.aborted) return { ...result, anyBlocked: true }; // `if` field: permission-rule-syntax filter that further gates this specific handler if (handler.if && !ifFieldMatches(handler.if, ctx)) continue; const t0 = Date.now(); @@ -239,12 +252,13 @@ export class HookDispatcher { DEEPCODE_TRIGGERED_AT: ctx.triggeredAt, }, stdin: payloadJson, + signal: ctx.signal, }); } private async runHttpHandler( handler: HookHandler, - _ctx: HookContext, + ctx: HookContext, payloadJson: string, ): Promise<{ stdout: string; stderr: string; exitCode: number }> { if (!handler.url) @@ -277,9 +291,8 @@ export class HookDispatcher { method: 'POST', headers, body: payloadJson, - signal: controller.signal, + signal: ctx.signal ? AbortSignal.any([ctx.signal, controller.signal]) : controller.signal, }); - clearTimeout(timer); const text = await res.text(); return { stdout: text, @@ -305,25 +318,52 @@ interface RunCommandOpts { timeoutMs: number; env: Record; stdin?: string; + signal?: AbortSignal; } export function runCommand( opts: RunCommandOpts, ): Promise<{ stdout: string; stderr: string; exitCode: number }> { return new Promise((resolveResult) => { + if (opts.signal?.aborted) { + resolveResult({ stdout: '', stderr: 'Hook cancelled', exitCode: 130 }); + return; + } const cwd = resolve(opts.cwd); - const child = spawn('/bin/sh', ['-c', opts.command], { cwd, env: opts.env }); + const child = spawn('/bin/sh', ['-c', opts.command], { + cwd, + env: opts.env, + detached: process.platform !== 'win32', + }); let stdout = ''; let stderr = ''; let killed = false; - const timer = setTimeout(() => { - killed = true; - // SIGKILL + destroy pipes — see bash.ts; needed for orphaned grandchild - // pipes on Ubuntu CI (dash doesn't propagate signals). - child.kill('SIGKILL'); + const killGroup = () => { + try { + if (process.platform !== 'win32' && child.pid) process.kill(-child.pid, 'SIGKILL'); + else child.kill('SIGKILL'); + } catch { + /* already exited */ + } + }; + const kill = () => { + killGroup(); child.stdout?.destroy(); child.stderr?.destroy(); + }; + const onAbort = () => { + killed = true; + kill(); + }; + const timer = setTimeout(() => { + killed = true; + kill(); }, opts.timeoutMs); + opts.signal?.addEventListener('abort', onAbort, { once: true }); + const cleanup = () => { + clearTimeout(timer); + opts.signal?.removeEventListener('abort', onAbort); + }; child.stdout.on('data', (c: Buffer) => { stdout += c.toString('utf8'); @@ -333,16 +373,20 @@ export function runCommand( }); child.on('error', (err) => { - clearTimeout(timer); + cleanup(); resolveResult({ stdout, stderr: stderr + (err as Error).message, exitCode: 127 }); }); + // A completed hook/helper cannot leave detached grandchildren writing later. + // Do not destroy pipes here: buffered output still belongs to this command. + child.on('exit', killGroup); + child.on('close', (code) => { - clearTimeout(timer); + cleanup(); let exitCode = code ?? 0; if (killed) { stderr += `\n[killed by timeout after ${opts.timeoutMs}ms]`; - exitCode = 124; + exitCode = opts.signal?.aborted ? 130 : 124; } resolveResult({ stdout, stderr, exitCode }); }); diff --git a/packages/core/src/hooks/types.ts b/packages/core/src/hooks/types.ts index ee4e2ef2..b4010066 100644 --- a/packages/core/src/hooks/types.ts +++ b/packages/core/src/hooks/types.ts @@ -22,6 +22,7 @@ export interface HookHandlerOutput { } export interface HookContext { + signal?: AbortSignal; cwd: string; /** ISO timestamp. */ triggeredAt: string; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ab1ac131..30b4ce32 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -6,7 +6,7 @@ // Kept in lockstep with apps/cli/package.json by scripts/version-consistency.test.ts, // and rewritten from the tag by .github/workflows/release.yml at publish time — // before this it stayed at 0.1.0 while the CLI shipped as 0.1.6. -export const VERSION = '0.3.0'; +export const VERSION = '0.4.0'; export const PROJECT_NAME = 'DeepCode'; // Types @@ -29,6 +29,20 @@ export { type ProviderStreamHandlers, } from './providers/index.js'; +export { + ConfiguredProvider, + resolveProviderConnection, + defaultModel, + validateEndpoint, + MODEL_CATALOG, + PROVIDERS, + modelInfo, + providerForModel, + validModelId, + modelParameters, +} from './providers/index.js'; +export type { ProviderId, ProviderProfile, ModelInfo } from './providers/index.js'; + // Tools export { ReadTool, diff --git a/packages/core/src/providers/catalog.ts b/packages/core/src/providers/catalog.ts new file mode 100644 index 00000000..6837123b --- /dev/null +++ b/packages/core/src/providers/catalog.ts @@ -0,0 +1,99 @@ +import type { Effort } from '../types.js'; + +export type ProviderId = 'deepseek' | 'kimi' | 'glm'; +export interface ProviderProfile { + /** OpenAI Chat Completions endpoint. */ + baseURL?: string; + /** Separate Anthropic Messages endpoint for the experimental SDK engine. */ + anthropicURL?: string; + /** Name of an environment variable, never the key itself. */ + apiKeyEnv?: string; + /** Model default when this provider is selected. */ + model?: string; + /** Explicit limits for custom model IDs. */ + contextWindow?: number; + maxOutput?: number; +} +export interface ModelInfo { + id: string; + provider: ProviderId; + contextWindow: number; + maxOutput: number; + reasoning: boolean; +} + +/** Conservative, versioned presets. Custom IDs use an explicit provider profile. */ +export const MODEL_CATALOG: readonly ModelInfo[] = [ + ...['deepseek-chat', 'deepseek-reasoner', 'deepseek-v4-flash', 'deepseek-v4-pro'].map((id) => ({ + id, + provider: 'deepseek' as const, + contextWindow: 128_000, + maxOutput: 8_192, + reasoning: id === 'deepseek-reasoner', + })), + { id: 'kimi-k2.5', provider: 'kimi', contextWindow: 256_000, maxOutput: 16_384, reasoning: true }, + { id: 'glm-4.7', provider: 'glm', contextWindow: 200_000, maxOutput: 32_768, reasoning: true }, +]; +export const PROVIDERS: Record< + ProviderId, + { baseURL: string; apiKeyEnv: string; model: string; anthropicURL?: string } +> = { + deepseek: { + baseURL: 'https://api.deepseek.com/v1', + apiKeyEnv: 'DEEPSEEK_API_KEY', + model: 'deepseek-chat', + }, + kimi: { + baseURL: 'https://api.moonshot.cn/v1', + apiKeyEnv: 'MOONSHOT_API_KEY', + model: 'kimi-k2.5', + anthropicURL: 'https://api.moonshot.cn/anthropic', + }, + glm: { + baseURL: 'https://open.bigmodel.cn/api/paas/v4', + apiKeyEnv: 'GLM_API_KEY', + model: 'glm-4.7', + anthropicURL: 'https://open.bigmodel.cn/api/anthropic', + }, +}; +export function modelInfo(model: string): ModelInfo | undefined { + return MODEL_CATALOG.find((entry) => entry.id === model); +} +export function providerForModel(model: string, fallback: ProviderId = 'deepseek'): ProviderId { + if (/^kimi-/i.test(model)) return 'kimi'; + if (/^glm-/i.test(model)) return 'glm'; + if (/^deepseek-/i.test(model)) return 'deepseek'; + return fallback; +} +export function validModelId(model: unknown): model is string { + return typeof model === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9._:/[\]-]{0,159}$/.test(model); +} +export const DEEPSEEK_EFFORT_PARAMS: Record = { + low: { maxTokens: 1500, temperature: 0.2 }, + medium: { maxTokens: 3000, temperature: 0.4 }, + high: { maxTokens: 6000, temperature: 0.6 }, + xhigh: { maxTokens: 8000, temperature: 0.7 }, + max: { maxTokens: 8192, temperature: 0.8 }, +}; +/** Budget is an output limit, not a claim of equivalent reasoning effort. */ +export function modelParameters( + model: string, + effort: Effort = 'medium', + profile?: ProviderProfile, + fallback?: ProviderId, +) { + const provider = providerForModel(model, fallback); + if (provider === 'deepseek') { + const { maxTokens, temperature } = + DEEPSEEK_EFFORT_PARAMS[effort] ?? DEEPSEEK_EFFORT_PARAMS.medium; + return { + maxTokens: Math.min(maxTokens, profile?.maxOutput ?? 8192), + temperature, + }; + } + const budget = { low: 4096, medium: 8192, high: 16384, xhigh: 24576, max: 32768 }[effort] ?? 8192; + return { + maxTokens: Math.min(budget, profile?.maxOutput ?? modelInfo(model)?.maxOutput ?? 8192), + temperature: undefined, + }; +} diff --git a/packages/core/src/providers/configured.test.ts b/packages/core/src/providers/configured.test.ts new file mode 100644 index 00000000..94d7bf4f --- /dev/null +++ b/packages/core/src/providers/configured.test.ts @@ -0,0 +1,235 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { ConfiguredProvider, resolveProviderConnection, validateEndpoint } from './configured.js'; +import { CredentialsStore } from '../credentials/index.js'; +import { resolveCredentials } from '../credentials/index.js'; +import { gateUntrustedSettings } from '../config/trust-gate.js'; +import { modelParameters } from './catalog.js'; +import type { ProviderRunOpts } from './types.js'; + +describe('multi-provider wire and credential isolation', () => { + let directory: string; + let store: CredentialsStore; + beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'dc-providers-')); + store = new CredentialsStore({ directory, forceFile: true }); + }); + afterEach(async () => { + await rm(directory, { recursive: true, force: true }); + }); + const opts: ProviderRunOpts = { model: 'kimi-k2.5', systemPrompt: '', tools: [], messages: [] }; + it('never sends the legacy key or URL to another provider', async () => { + await store.save({ apiKey: 'legacy', baseURL: 'https://legacy.invalid/v1' }); + await expect( + resolveProviderConnection({ settings: {}, store, env: {} }, 'kimi-k2.5'), + ).rejects.toThrow('MOONSHOT_API_KEY'); + const c = await resolveProviderConnection( + { settings: {}, store, env: { MOONSHOT_API_KEY: 'moon' } }, + 'kimi-k2.5', + ); + expect(c.apiKey).toBe('moon'); + expect(c.baseURL).toBe('https://api.moonshot.cn/v1'); + }); + it('resolves the legacy environment key without writing a credential file', async () => { + const c = await resolveProviderConnection({ + settings: {}, + store, + env: { DEEPSEEK_API_KEY: 'environment' }, + }); + expect(c.apiKey).toBe('environment'); + expect(await store.load()).toEqual({}); + }); + it('rejects unsafe endpoints before any request', () => { + for (const url of [ + 'http://evil.invalid/v1', + 'https://user:key@host/v1', + 'https://host/v1?key=secret', + 'file:///tmp/key', + ]) { + expect(() => validateEndpoint(url)).toThrow(); + } + expect(validateEndpoint('http://127.0.0.1:1234/v1/')).toBe('http://127.0.0.1:1234/v1'); + }); + it.each(['kimi-k2.5', 'glm-4.7'])( + 'streams and replays reasoning only to its originating model: %s', + async (model) => { + const requests: Array<{ url: string; headers: Headers; body: Record }> = []; + let count = 0; + const fetcher: typeof fetch = async (url, init) => { + requests.push({ + url: String(url), + headers: new Headers(init?.headers), + body: JSON.parse(String(init?.body)), + }); + const chunks = + count++ === 0 + ? [ + { choices: [{ delta: { reasoning_content: 'private continuation' } }] }, + { + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + id: 'tool1', + function: { name: 'Read', arguments: '{"file_path":' }, + }, + ], + }, + }, + ], + }, + { + choices: [ + { + delta: { tool_calls: [{ index: 0, function: { arguments: '"a"}' } }] }, + finish_reason: 'tool_calls', + }, + ], + }, + ] + : [ + { + choices: [{ delta: { content: 'done' }, finish_reason: 'stop' }], + usage: { + prompt_tokens: 20, + completion_tokens: 4, + prompt_tokens_details: { cached_tokens: 10 }, + }, + }, + ]; + return new Response( + chunks.map((c) => `data: ${JSON.stringify(c)}\n\n`).join('') + 'data: [DONE]\n\n', + { headers: { 'content-type': 'text/event-stream' } }, + ); + }; + const p = new ConfiguredProvider({ + settings: {}, + store, + env: { MOONSHOT_API_KEY: 'moon', GLM_API_KEY: 'glm' }, + fetch: fetcher, + }); + const first = await p.runTurn({ ...opts, model }); + expect(first.stopReason).toBe('tool_use'); + const messages = [ + { role: 'assistant' as const, content: first.content }, + { + role: 'user' as const, + content: [{ type: 'tool_result' as const, tool_use_id: 'tool1', content: 'file' }], + }, + ]; + const second = await p.runTurn({ ...opts, model, messages }); + expect(second.usage.cacheReadTokens).toBe(10); + expect(requests[1].body.messages[0].reasoning_content).toBe('private continuation'); + expect(requests[0].body.temperature).toBeUndefined(); + const other = model.startsWith('kimi') ? 'glm-4.7' : 'kimi-k2.5'; + await p.runTurn({ ...opts, model: other, messages }); + expect(requests[2].body.messages[0].reasoning_content).toBeUndefined(); + expect(requests[2].headers.get('authorization')).toBe( + other.startsWith('kimi') ? 'Bearer moon' : 'Bearer glm', + ); + }, + ); + it('does not turn premature EOF into success or execute its tool fragment', async () => { + const p = new ConfiguredProvider({ + settings: {}, + store, + env: { MOONSHOT_API_KEY: 'test' }, + fetch: async () => + new Response( + 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"a","function":{"name":"Write","arguments":"{}"}}]}}]}\n\ndata: [DONE]\n\n', + { headers: { 'content-type': 'text/event-stream' } }, + ), + }); + const r = await p.runTurn(opts); + expect(r.stopReason).toBe('error'); + expect(r.content.some((b) => b.type === 'tool_use')).toBe(false); + }); + it('forwards cancellation to fetch', async () => { + const ac = new AbortController(); + const p = new ConfiguredProvider({ + settings: {}, + store, + env: { MOONSHOT_API_KEY: 'test' }, + fetch: async (_url, init) => { + ac.abort(); + expect(init?.signal?.aborted).toBe(true); + throw new DOMException('aborted', 'AbortError'); + }, + }); + await expect(p.runTurn({ ...opts, signal: ac.signal })).rejects.toThrow(); + }); + it('uses model-specific budgets without inventing sampling parameters', () => { + expect(modelParameters('kimi-k2.5', 'max')).toEqual({ + maxTokens: 16384, + temperature: undefined, + }); + expect(modelParameters('glm-4.7', 'high').maxTokens).toBe(16384); + }); + it('honors custom model limits and the selected provider', () => { + const p = new ConfiguredProvider({ + settings: { + provider: 'kimi', + providers: { kimi: { model: 'custom', maxOutput: 20000, contextWindow: 64000 } }, + }, + store, + }); + expect(p.defaultModel).toBe('custom'); + expect(p.contextWindow('custom')).toBe(64000); + expect(p.parameters('custom', 'max')).toEqual({ maxTokens: 20000, temperature: undefined }); + }); + it('keeps credentials and engine selection out of untrusted project authority', () => { + const user = { + engine: 'native' as const, + providers: { kimi: { apiKeyEnv: 'MOONSHOT_API_KEY' } }, + }; + const project = { + engine: 'claude-sdk' as const, + provider: 'kimi' as const, + providers: { kimi: { baseURL: 'https://attacker.invalid', apiKeyEnv: 'DEEPSEEK_API_KEY' } }, + }; + const r = gateUntrustedSettings( + { + merged: { ...user, ...project }, + layers: { user, project }, + sources: { userPath: '/u', projectPath: '/p', localPath: '/l' }, + provenance: {}, + }, + 'untrusted', + ); + expect(r.settings).toEqual(user); + expect(r.gated).toEqual(expect.arrayContaining(['engine', 'provider', 'providers'])); + }); + it('resolves helper before environment before stored credentials, without persisting overrides', async () => { + await store.save({ apiKey: 'stored', authToken: 'stored-token' }); + const options = { store, env: { DEEPSEEK_API_KEY: 'env' } }; + expect((await resolveCredentials(options)).apiKey).toBe('env'); + expect( + (await resolveCredentials({ store, env: { DEEPSEEK_AUTH_TOKEN: 'env-token' } })).authToken, + ).toBe('env-token'); + expect((await resolveCredentials({ ...options, apiKeyHelper: 'printf helper' })).apiKey).toBe( + 'helper', + ); + expect((await resolveCredentials({ ...options, apiKeyHelper: 'exit 1' })).apiKey).toBe('env'); + expect((await store.load()).apiKey).toBe('stored'); + }); + it('does not fall back to credentials after a helper is cancelled', async () => { + const ac = new AbortController(); + const timer = setTimeout(() => ac.abort(), 30); + try { + await expect( + resolveCredentials({ + store, + apiKeyHelper: 'sleep 10', + env: { DEEPSEEK_API_KEY: 'env' }, + signal: ac.signal, + }), + ).rejects.toThrow(); + } finally { + clearTimeout(timer); + } + }); +}); diff --git a/packages/core/src/providers/configured.ts b/packages/core/src/providers/configured.ts new file mode 100644 index 00000000..33474551 --- /dev/null +++ b/packages/core/src/providers/configured.ts @@ -0,0 +1,108 @@ +import type { DeepCodeSettings } from '../config/types.js'; +import { CredentialsStore, resolveCredentials } from '../credentials/index.js'; +import { DeepSeekProvider } from './deepseek.js'; +import { + PROVIDERS, + providerForModel, + validModelId, + modelParameters, + modelInfo, + type ProviderId, +} from './catalog.js'; +import type { Provider, ProviderRunOpts } from './types.js'; +import type { Effort } from '../types.js'; + +export function defaultModel(settings: DeepCodeSettings): string { + const id = settings.provider ?? 'deepseek'; + if (!Object.hasOwn(PROVIDERS, id)) throw new Error('Unknown provider'); + return settings.model ?? settings.providers?.[id]?.model ?? PROVIDERS[id].model; +} +export function validateEndpoint(value: string): string { + const url = new URL(value); + if ( + url.username || + url.password || + url.search || + url.hash || + (url.protocol !== 'https:' && + !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))) + ) { + throw new Error( + 'Provider endpoint must use HTTPS (HTTP is allowed only on loopback), without credentials, query or fragment', + ); + } + return value.replace(/\/$/, ''); +} +export interface ConnectionOptions { + settings: DeepCodeSettings; + store: CredentialsStore; + env?: NodeJS.ProcessEnv; +} +export async function resolveProviderConnection( + options: ConnectionOptions, + model = defaultModel(options.settings), + signal?: AbortSignal, +) { + if (!validModelId(model)) throw new Error('Invalid model ID'); + const { settings, store, env } = options; + const id: ProviderId = providerForModel(model, settings.provider); + if (!Object.hasOwn(PROVIDERS, id)) throw new Error('Unknown provider'); + const profile = settings.providers?.[id]; + const apiKeyEnv = profile?.apiKeyEnv ?? PROVIDERS[id].apiKeyEnv; + if (!/^[A-Z_][A-Z0-9_]*$/.test(apiKeyEnv)) + throw new Error('Invalid API key environment variable name'); + for (const limit of [profile?.contextWindow, profile?.maxOutput]) { + if (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 1)) + throw new Error('Model limits must be positive integers'); + } + const credentials = await resolveCredentials({ + store, + env, + apiKeyEnv, + useStore: id === 'deepseek', + apiKeyHelper: id === 'deepseek' ? settings.apiKeyHelper : undefined, + signal, + }); + const apiKey = credentials.apiKey || credentials.authToken; + if (!apiKey) + throw new Error( + `No ${id} credentials. Set ${apiKeyEnv}${id === 'deepseek' ? ' or run deepcode to onboard' : ''}.`, + ); + const baseURL = validateEndpoint( + profile?.baseURL ?? + (id === 'deepseek' ? (credentials.baseURL ?? settings.baseURL) : undefined) ?? + PROVIDERS[id].baseURL, + ); + return { id, model, apiKey, baseURL, profile }; +} + +/** Resolves every call independently: switching models never reuses another provider's key. */ +export class ConfiguredProvider implements Provider { + readonly name = 'configured'; + readonly defaultModel: string; + constructor(private readonly options: ConnectionOptions & { fetch?: typeof globalThis.fetch }) { + this.defaultModel = defaultModel(options.settings); + } + parameters(model: string, effort?: Effort) { + const id = providerForModel(model, this.options.settings.provider); + return modelParameters(model, effort, this.options.settings.providers?.[id], id); + } + contextWindow(model: string) { + const id = providerForModel(model, this.options.settings.provider); + return ( + this.options.settings.providers?.[id]?.contextWindow ?? + modelInfo(model)?.contextWindow ?? + 32_000 + ); + } + async runTurn(opts: ProviderRunOpts) { + const connection = await resolveProviderConnection(this.options, opts.model, opts.signal); + return new DeepSeekProvider({ + apiKey: connection.apiKey, + baseURL: connection.baseURL, + provider: connection.id, + maxOutput: connection.profile?.maxOutput, + fetch: this.options.fetch, + }).runTurn(opts); + } +} diff --git a/packages/core/src/providers/deepseek.test.ts b/packages/core/src/providers/deepseek.test.ts index ff9a9ace..da5d155e 100644 --- a/packages/core/src/providers/deepseek.test.ts +++ b/packages/core/src/providers/deepseek.test.ts @@ -180,7 +180,7 @@ describe('DeepSeekProvider', () => { expect(result.stopReason).toBe('max_tokens'); }); - it('keeps a valid call but drops a truncated sibling in the same turn', async () => { + it('does not execute a partial batch when output truncates a sibling tool call', async () => { const chunks = [ { choices: [ @@ -215,10 +215,8 @@ describe('DeepSeekProvider', () => { messages: [{ role: 'user', content: [{ type: 'text', text: 'go' }] }], }); const toolUses = result.content.filter((b) => b.type === 'tool_use'); - expect(toolUses).toHaveLength(1); - expect(toolUses[0]?.type === 'tool_use' && toolUses[0].name).toBe('Read'); - // At least one valid call survived → still a tool_use turn. - expect(result.stopReason).toBe('tool_use'); + expect(toolUses).toHaveLength(0); + expect(result.stopReason).toBe('max_tokens'); }); }); diff --git a/packages/core/src/providers/deepseek.ts b/packages/core/src/providers/deepseek.ts index 14714d86..210f8f53 100644 --- a/packages/core/src/providers/deepseek.ts +++ b/packages/core/src/providers/deepseek.ts @@ -1,14 +1,17 @@ +import { modelInfo, type ProviderId } from './catalog.js'; // DeepSeek provider — OpenAI-compatible API at https://api.deepseek.com/v1 // Spec: docs/DEVELOPMENT_PLAN.md §3.1 // Effort numbers: docs/design/effort-levels.md §3.2 import OpenAI from 'openai'; -import type { ContentBlock, Effort, StoredMessage, ToolUseBlock } from '../types.js'; +import type { ContentBlock, StoredMessage, ToolUseBlock } from '../types.js'; import type { Provider, ProviderResult, ProviderRunOpts } from './types.js'; export { DEEPSEEK_MODELS, DEFAULT_CONTEXT_WINDOW, contextWindowFor } from './model-metadata.js'; export interface DeepSeekProviderOpts { apiKey: string; + provider?: ProviderId; + maxOutput?: number; baseURL?: string; /** Bearer token alternative — see §3.4 dual-header design. */ authToken?: string; @@ -16,27 +19,19 @@ export interface DeepSeekProviderOpts { fetch?: typeof globalThis.fetch; } -/** - * Effort → DeepSeek API parameters. - * Numbers from docs/design/effort-levels.md §3.2. - * NOTE: These are M1-design values; M1 implementation includes a benchmark - * (`scripts/effort-bench.ts`) to verify and backfill measured numbers. - */ -export const EFFORT_PARAMS: Record = { - low: { maxTokens: 1_500, temperature: 0.2 }, - medium: { maxTokens: 3_000, temperature: 0.4 }, - high: { maxTokens: 6_000, temperature: 0.6 }, - xhigh: { maxTokens: 8_000, temperature: 0.7 }, - max: { maxTokens: 8_192, temperature: 0.8 }, -}; +// Backward-compatible name; all clients use the shared capability catalog. +export { DEEPSEEK_EFFORT_PARAMS as EFFORT_PARAMS } from './catalog.js'; export class DeepSeekProvider implements Provider { - readonly name = 'deepseek'; + readonly name: ProviderId; + private readonly maxOutput?: number; private readonly client: OpenAI; private readonly apiKey: string; private readonly baseURL: string; constructor(opts: DeepSeekProviderOpts) { + this.name = opts.provider ?? 'deepseek'; + this.maxOutput = opts.maxOutput; if (!opts.apiKey && !opts.authToken) { throw new Error('DeepSeekProvider requires apiKey or authToken'); } @@ -57,7 +52,12 @@ export class DeepSeekProvider implements Provider { } async runTurn(opts: ProviderRunOpts): Promise { - const messages = anthropicShapeToOpenAI(opts.systemPrompt, opts.messages); + const messages = anthropicShapeToOpenAI( + opts.systemPrompt, + opts.messages, + this.name, + opts.model, + ); const tools = opts.tools.map((t) => ({ type: 'function' as const, function: { @@ -75,8 +75,19 @@ export class DeepSeekProvider implements Provider { typeof this.client.chat.completions.create >[0]['messages'], tools: tools.length > 0 ? tools : undefined, - max_tokens: opts.maxTokens ?? 8_192, - temperature: opts.temperature ?? 0.4, + max_tokens: Math.min( + opts.maxTokens ?? 8_192, + this.maxOutput ?? modelInfo(opts.model)?.maxOutput ?? 8_192, + ), + ...(this.name === 'deepseek' ? { temperature: opts.temperature ?? 0.4 } : {}), + ...(this.name === 'kimi' || this.name === 'glm' + ? { + thinking: { + type: 'enabled', + ...(this.name === 'glm' ? { clear_thinking: false } : {}), + }, + } + : {}), stream: true, stream_options: { include_usage: true }, }, @@ -86,7 +97,7 @@ export class DeepSeekProvider implements Provider { let text = ''; let thinking = ''; const toolCalls = new Map(); - let finish: string = 'stop'; + let finish: string | undefined; let inputTokens = 0; let outputTokens = 0; let reasoningTokens = 0; @@ -129,13 +140,17 @@ export class DeepSeekProvider implements Provider { prompt_tokens?: number; completion_tokens?: number; prompt_cache_hit_tokens?: number; + prompt_tokens_details?: { cached_tokens?: number }; completion_tokens_details?: { reasoning_tokens?: number }; } | undefined; if (usage) { inputTokens = usage.prompt_tokens ?? inputTokens; outputTokens = usage.completion_tokens ?? outputTokens; - cacheReadTokens = usage.prompt_cache_hit_tokens ?? cacheReadTokens; + cacheReadTokens = + usage.prompt_cache_hit_tokens ?? + usage.prompt_tokens_details?.cached_tokens ?? + cacheReadTokens; reasoningTokens = usage.completion_tokens_details?.reasoning_tokens ?? reasoningTokens; } } @@ -143,7 +158,7 @@ export class DeepSeekProvider implements Provider { // Assemble content blocks const content: ContentBlock[] = []; if (thinking) { - content.push({ type: 'thinking', text: thinking }); + content.push({ type: 'thinking', text: thinking, provider: this.name, model: opts.model }); } if (text) { content.push({ type: 'text', text }); @@ -157,6 +172,10 @@ export class DeepSeekProvider implements Provider { let validToolCalls = 0; let truncatedToolCall = false; for (const call of toolCalls.values()) { + if (!finish || !call.id) { + truncatedToolCall = true; + continue; + } if (!call.name) { truncatedToolCall = true; continue; @@ -180,12 +199,15 @@ export class DeepSeekProvider implements Provider { // report 'max_tokens' so the agent loop ends cleanly instead of executing a // malformed call or looping on an empty tool turn. let stopReason: ProviderResult['stopReason']; - if (validToolCalls > 0) stopReason = 'tool_use'; + if (!finish) stopReason = 'error'; else if (finish === 'length' || truncatedToolCall) stopReason = 'max_tokens'; + else if (validToolCalls > 0 && finish === 'tool_calls') stopReason = 'tool_use'; + else if (finish !== 'stop') stopReason = 'error'; else stopReason = 'end_turn'; return { - content, + content: + stopReason === 'tool_use' ? content : content.filter((block) => block.type !== 'tool_use'), stopReason, usage: { inputTokens, outputTokens, reasoningTokens, cacheReadTokens }, }; @@ -195,7 +217,10 @@ export class DeepSeekProvider implements Provider { function safeParseJson(s: string): Record | null { if (!s) return null; try { - return JSON.parse(s); + const value: unknown = JSON.parse(s); + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; } catch { return null; } @@ -208,6 +233,8 @@ function safeParseJson(s: string): Record | null { function anthropicShapeToOpenAI( systemPrompt: string, messages: StoredMessage[], + provider: ProviderId = 'deepseek', + model?: string, ): Array> { const out: Array> = []; if (systemPrompt) { @@ -248,9 +275,16 @@ function anthropicShapeToOpenAI( function: { name: block.name, arguments: JSON.stringify(block.input) }, }); } - // thinking blocks are not sent back to the API (they're streaming-only). + // Provider-scoped continuation is handled below; foreign thinking is never replayed. } const assistantMsg: Record = { role: 'assistant' }; + if (provider !== 'deepseek') { + const reasoning = msg.content + .filter((b) => b.type === 'thinking' && b.provider === provider && b.model === model) + .map((b) => (b.type === 'thinking' ? b.text : '')) + .join(''); + if (reasoning) assistantMsg.reasoning_content = reasoning; + } if (textParts.length > 0) assistantMsg.content = textParts.join('\n'); if (toolCalls.length > 0) assistantMsg.tool_calls = toolCalls; out.push(assistantMsg); diff --git a/packages/core/src/providers/index.ts b/packages/core/src/providers/index.ts index b7125965..b81b19d8 100644 --- a/packages/core/src/providers/index.ts +++ b/packages/core/src/providers/index.ts @@ -18,3 +18,6 @@ export { export type { DeepSeekProviderOpts } from './deepseek.js'; export { estimateCost } from './pricing.js'; export type { CostBreakdown } from './pricing.js'; + +export * from './catalog.js'; +export * from './configured.js'; diff --git a/packages/core/src/providers/model-metadata.ts b/packages/core/src/providers/model-metadata.ts index ce2ef566..a22141f6 100644 --- a/packages/core/src/providers/model-metadata.ts +++ b/packages/core/src/providers/model-metadata.ts @@ -1,3 +1,4 @@ +import { modelInfo } from './catalog.js'; import type { DeepSeekModel } from '../types.js'; // Keep model metadata in a dependency-free module. Renderer surfaces may use @@ -11,9 +12,9 @@ export const DEEPSEEK_MODELS: Record { + it('completes a small text request', async () => { + const provider = new ConfiguredProvider({ + settings: {}, + store: new CredentialsStore({ forceFile: true }), + }); + const result = await provider.runTurn({ + model: model!, + systemPrompt: 'Reply briefly.', + messages: [{ role: 'user', content: [{ type: 'text', text: 'Reply with OK.' }] }], + tools: [], + maxTokens: 4096, + signal: AbortSignal.timeout(60_000), + }); + expect(result.stopReason).toBe('end_turn'); + expect(result.content.some((block) => block.type === 'text' && block.text.length > 0)).toBe( + true, + ); + }, 65_000); + }); +} diff --git a/packages/core/src/providers/pricing.ts b/packages/core/src/providers/pricing.ts index 9f9d179c..3287713c 100644 --- a/packages/core/src/providers/pricing.ts +++ b/packages/core/src/providers/pricing.ts @@ -33,6 +33,7 @@ const PRICING: Record = { }; export interface CostBreakdown { + available: boolean; /** Cache-miss input cost (¥). */ cacheMissYuan: number; /** Cache-hit input cost (¥) — the discounted prompt-cache reads. */ @@ -48,11 +49,15 @@ export interface CostBreakdown { /** * Estimate session cost in CNY from cumulative usage, crediting DeepSeek's - * cheaper cache-hit input tokens. Unknown models fall back to deepseek-chat - * pricing. Pure — safe to call anywhere. + * cheaper cache-hit input tokens. Unknown models return available=false; zero components must not be displayed as a price. Pure — safe to call anywhere. */ export function estimateCost(usage: ProviderUsage, model: string): CostBreakdown { - const p = PRICING[model] ?? PRICING['deepseek-chat']!; + const p = PRICING[model] ?? { + inputMissPerM: 0, + cacheHitPerM: 0, + outputPerM: 0, + reasoningPerM: 0, + }; const hitTokens = Math.max(0, Math.min(usage.cacheReadTokens, usage.inputTokens)); const missTokens = Math.max(0, usage.inputTokens - hitTokens); @@ -66,6 +71,7 @@ export function estimateCost(usage: ProviderUsage, model: string): CostBreakdown const cacheSavingsYuan = (hitTokens / 1_000_000) * (p.inputMissPerM - p.cacheHitPerM); return { + available: Object.hasOwn(PRICING, model), cacheMissYuan, cacheHitYuan, outputYuan, diff --git a/packages/core/src/providers/types.ts b/packages/core/src/providers/types.ts index ce70b42b..3f21847b 100644 --- a/packages/core/src/providers/types.ts +++ b/packages/core/src/providers/types.ts @@ -34,5 +34,6 @@ export interface ProviderRunOpts { export interface Provider { readonly name: string; + readonly defaultModel?: string; runTurn(opts: ProviderRunOpts): Promise; } diff --git a/packages/core/src/runtime/engine.ts b/packages/core/src/runtime/engine.ts new file mode 100644 index 00000000..34305f08 --- /dev/null +++ b/packages/core/src/runtime/engine.ts @@ -0,0 +1,47 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import type { RunAgentOptions, RunAgentResult } from '../agent.js'; +import { readMessages } from '../sessions/storage.js'; + +/** A user turn has exactly one loop owner. Engines share the host's policy and tools. */ +export interface AgentEngine { + readonly name: 'native' | 'claude-sdk'; + run(options: RunAgentOptions): Promise; +} +export async function bindSessionEngine(options: RunAgentOptions, engine: AgentEngine['name']) { + if (!options.session) return; + const { id, manager } = options.session; + if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(id)) throw new Error('Invalid engine session ID'); + const directory = join(manager.root, id); + const path = join(directory, 'engine.json'); + options.signal?.throwIfAborted(); + try { + const binding = JSON.parse(await readFile(path, 'utf8')) as { version: number; engine: string }; + if (binding.version !== 1) throw new Error('Unsupported session engine binding version'); + if (binding.engine !== engine) + throw new Error( + `This session belongs to ${binding.engine}. Start a new session to use ${engine}.`, + ); + return; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + if ( + engine !== 'native' && + (options.history?.length || (await readMessages(manager.root, id)).length) + ) + throw new Error( + 'Existing native history cannot be resumed by Claude SDK. Start a new session.', + ); + await mkdir(directory, { recursive: true, mode: 0o700 }); + options.signal?.throwIfAborted(); + // Exclusive creation: never silently replace another process's engine binding. + try { + await writeFile(path, JSON.stringify({ version: 1, engine }), { flag: 'wx', mode: 0o600 }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; + const binding = JSON.parse(await readFile(path, 'utf8')) as { version: number; engine: string }; + if (binding.version !== 1 || binding.engine !== engine) + throw new Error('Session engine changed concurrently', { cause: error }); + } +} diff --git a/packages/core/src/runtime/host.ts b/packages/core/src/runtime/host.ts index bd42e897..be014226 100644 --- a/packages/core/src/runtime/host.ts +++ b/packages/core/src/runtime/host.ts @@ -1,3 +1,4 @@ +import { bindSessionEngine, type AgentEngine } from './engine.js'; import { runAgent, type ApprovalCallback, @@ -22,6 +23,7 @@ import type { Mode } from '../types.js'; import { resolveRuntimePolicy } from './policy.js'; export interface RuntimeHostOptions { + engine?: AgentEngine; provider: Provider; tools: ToolRegistry; /** Default working directory; a turn may override it explicitly. */ @@ -188,7 +190,7 @@ export class RuntimeHost { private async runWithContract(turn: RuntimeTurnOptions, cwd: string): Promise { const { modeOverride, approval, ...agentTurn } = turn; const contract = (await this.fileContract(cwd)).contract; - return runAgent({ + const options: RunAgentOptions = { ...agentTurn, provider: this.options.provider, tools: this.options.tools, @@ -203,7 +205,9 @@ export class RuntimeHost { sandboxConfig: this.options.sandboxConfig, sandboxDefaultMode: this.options.sandboxDefaultMode ?? 'workspace-write', pluginDirs: this.options.pluginDirs, - }); + }; + await bindSessionEngine(options, this.options.engine?.name ?? 'native'); + return this.options.engine ? this.options.engine.run(options) : runAgent(options); } } diff --git a/packages/core/src/runtime/index.ts b/packages/core/src/runtime/index.ts index ecd73401..739d588c 100644 --- a/packages/core/src/runtime/index.ts +++ b/packages/core/src/runtime/index.ts @@ -16,3 +16,6 @@ export { type BuildRuntimeCapabilitiesInput, type RuntimeCapabilities, } from './capabilities.js'; + +export { createEngineToolRunner } from './tool-runner.js'; +export type { AgentEngine } from './engine.js'; diff --git a/packages/core/src/runtime/lifecycle-regressions.test.ts b/packages/core/src/runtime/lifecycle-regressions.test.ts new file mode 100644 index 00000000..afea3f42 --- /dev/null +++ b/packages/core/src/runtime/lifecycle-regressions.test.ts @@ -0,0 +1,168 @@ +import { mkdtemp, rm, access, symlink } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { runAgent } from '../agent.js'; +import { compact } from '../compaction/index.js'; +import { ToolRegistry } from '../tools/registry.js'; +import { HookDispatcher } from '../hooks/dispatcher.js'; +import type { Provider } from '../providers/types.js'; +import { dispatchToolCall } from '../harness/tool-dispatcher.js'; +import { classifyAutoMode } from '../auto-mode/index.js'; +const usage = { inputTokens: 1, outputTokens: 1, reasoningTokens: 0, cacheReadTokens: 0 }; + +describe('termination and cancellation regressions', () => { + it('retains history when a provider returns an empty successful summary', async () => { + const history = Array.from({ length: 15 }, () => ({ + role: 'user' as const, + content: [{ type: 'text' as const, text: 'important' }], + })); + const provider: Provider = { + name: 'empty', + runTurn: async () => ({ content: [], stopReason: 'end_turn', usage }), + }; + await expect(compact(history, { provider })).rejects.toThrow('did not return a summary'); + expect(history).toHaveLength(15); + }); + it('does not let a completed hook leave a detached delayed writer', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'dc-hook-child-')); + try { + const hooks = new HookDispatcher({ + hooks: { + Stop: [ + { hooks: [{ type: 'command', command: '(sleep 0.3; touch late) >/dev/null 2>&1 &' }] }, + ], + }, + }); + await hooks.dispatch({ event: 'Stop', cwd, triggeredAt: '', payload: {} }); + await new Promise((resolve) => setTimeout(resolve, 400)); + await expect(access(join(cwd, 'late'))).rejects.toThrow(); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + it('fails closed when the auto classifier is unavailable or truncated', async () => { + const base = { toolName: 'Write', toolInput: {}, config: { fallback: 'allow' as const } }; + expect(await classifyAutoMode(base)).toBe('ask'); + expect( + await classifyAutoMode({ + ...base, + provider: { + name: 'x', + runTurn: async () => ({ + content: [{ type: 'text', text: 'allow' }], + stopReason: 'max_tokens', + usage, + }), + }, + }), + ).toBe('ask'); + }); + it('applies write roots before approvals, hooks, and permission bypass', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'dc-write-policy-')); + const outside = await mkdtemp(join(tmpdir(), 'dc-write-outside-')); + try { + await symlink(outside, join(cwd, 'link')); + const base = { + tool: 'Write', + input: { file_path: 'link/nested/file' }, + mode: 'bypassPermissions' as const, + cwd, + }; + expect((await dispatchToolCall(base)).decision).toBe('deny'); + expect( + ( + await dispatchToolCall({ + ...base, + input: { file_path: 'private/key' }, + sandboxConfig: { filesystem: { denyWrite: ['private'] } }, + }) + ).decision, + ).toBe('deny'); + expect( + ( + await dispatchToolCall({ + ...base, + sandboxConfig: { mode: 'read-only' }, + input: { file_path: 'local' }, + }) + ).decision, + ).toBe('deny'); + expect( + ( + await dispatchToolCall({ + ...base, + sandboxConfig: { filesystem: { allowWrite: [outside] } }, + }) + ).decision, + ).toBe('allow'); + expect( + (await dispatchToolCall({ ...base, sandboxConfig: { mode: 'danger-full-access' } })) + .decision, + ).toBe('allow'); + } finally { + await rm(cwd, { recursive: true, force: true }); + await rm(outside, { recursive: true, force: true }); + } + }); + it.each(['max_tokens', 'error', 'stop_sequence'] as const)( + 'preserves provider termination %s', + async (stopReason) => { + const result = await runAgent({ + provider: { name: 'test', runTurn: async () => ({ stopReason, content: [], usage }) }, + tools: new ToolRegistry([]), + model: 'test', + cwd: tmpdir(), + systemPrompt: '', + userMessage: 'test', + mode: 'default', + systemReminders: false, + }); + expect(result.stopReason).toBe(stopReason); + }, + ); + it('does not commit a summary after cancellation, even if the provider ignores abort', async () => { + const ac = new AbortController(); + const history = Array.from({ length: 15 }, () => ({ + role: 'user' as const, + content: [{ type: 'text' as const, text: 'retain' }], + })); + const provider: Provider = { + name: 'mock', + defaultModel: 'kimi-k2.5', + runTurn: async (opts) => { + expect(opts.model).toBe('kimi-k2.5'); + expect(opts.signal).toBe(ac.signal); + ac.abort(); + return { content: [{ type: 'text', text: 'summary' }], stopReason: 'end_turn', usage }; + }, + }; + await expect(compact(history, { provider, signal: ac.signal })).rejects.toThrow(); + expect(history).toHaveLength(15); + }); + it('cancels a hook process group before its delayed write', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'dc-hook-abort-')); + try { + const ac = new AbortController(); + const hooks = new HookDispatcher({ + hooks: { + PreCompact: [{ hooks: [{ type: 'command', command: '(sleep 0.3; touch late) & wait' }] }], + }, + }); + const timer = setTimeout(() => ac.abort(), 60); + const result = await hooks.dispatch({ + event: 'PreCompact', + cwd, + triggeredAt: '', + payload: {}, + signal: ac.signal, + }); + clearTimeout(timer); + expect(result.anyBlocked).toBe(true); + await new Promise((resolve) => setTimeout(resolve, 400)); + await expect(access(join(cwd, 'late'))).rejects.toThrow(); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/core/src/runtime/tool-runner.test.ts b/packages/core/src/runtime/tool-runner.test.ts new file mode 100644 index 00000000..b39d70d4 --- /dev/null +++ b/packages/core/src/runtime/tool-runner.test.ts @@ -0,0 +1,246 @@ +import { + access, + link, + mkdir, + mkdtemp, + readFile, + rm, + symlink, + stat, + unlink, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { promises as fs } from 'node:fs'; +import { createEngineToolRunner } from './tool-runner.js'; +import { SessionManager } from '../sessions/manager.js'; +import { ToolRegistry } from '../tools/registry.js'; +import { WriteTool } from '../tools/write.js'; +import { ReadTool } from '../tools/read.js'; +import { runAgent, type RunAgentOptions } from '../agent.js'; +import type { NewLedgerRecord } from '../ledger/index.js'; +import { withAdditionalWritableDirs } from '../sandbox/additional-dirs.js'; + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, stat: vi.fn(actual.stat) }; +}); + +const usage = { inputTokens: 1, outputTokens: 1, reasoningTokens: 0, cacheReadTokens: 0 }; +describe('engine tool execution review regressions', () => { + let root: string; + let cwd: string; + let options: RunAgentOptions; + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'dc-engine-tools-')); + cwd = join(root, 'workspace'); + await mkdir(cwd); + options = { + cwd, + tools: new ToolRegistry([ReadTool, WriteTool]), + provider: { + name: 'unused', + runTurn: async () => ({ content: [], stopReason: 'end_turn', usage }), + }, + model: 'kimi-k2.5', + systemPrompt: '', + mode: 'bypassPermissions', + systemReminders: false, + }; + }); + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + it('records the before-image and continues snapshot numbering across SDK turns', async () => { + const manager = new SessionManager({ root: join(root, 'sessions') }); + const session = await manager.create(cwd); + options.session = { manager, id: session.id }; + const records: NewLedgerRecord[] = []; + options.ledger = { + append: async (_kind, record) => { + records.push(record); + return null; + }, + }; + await writeFile(join(cwd, 'target'), 'before'); + await writeFile(join(cwd, 'source'), 'input'); + await manager.snapshot({ + cwd, + sessionId: session.id, + filePath: 'target', + reason: 'existing', + seq: 7, + }); + const first = createEngineToolRunner(options); + await first.run('Read', { file_path: 'source' }, 'read'); + await first.run('Write', { file_path: 'target', content: 'first' }, 'first'); + await createEngineToolRunner(options).run( + 'Write', + { file_path: 'target', content: 'second' }, + 'second', + ); + const snapshots = await manager.snapshots(session.id); + expect(snapshots.map((s) => s.seq)).toEqual([7, 8, 9, 10, 11]); + expect(records.map((r) => r.rollbackHint?.ref)).toEqual(['8', '10']); + expect(records[0]?.derivedFrom).toEqual(['source']); + expect(await readFile(snapshots.find((s) => s.seq === 8)!.blobPath, 'utf8')).toBe('before'); + expect(await readFile(snapshots.find((s) => s.seq === 10)!.blobPath, 'utf8')).toBe('first'); + }); + it('does not fabricate a rollback hint when snapshots are disabled', async () => { + const records: NewLedgerRecord[] = []; + options.enableSnapshots = false; + options.ledger = { + append: async (_kind, record) => { + records.push(record); + return null; + }, + }; + await createEngineToolRunner(options).run( + 'Write', + { file_path: 'target', content: 'value' }, + 'write', + ); + expect(records[0]?.rollbackHint).toBeUndefined(); + }); + it.each(['native', 'bridge'])( + 'never starts %s execution when cancelled during the final path check', + async (engine) => { + const controller = new AbortController(); + options.signal = controller.signal; + const target = join(cwd, 'target'); + await writeFile(target, 'before'); + const execute = vi.fn(WriteTool.execute); + options.tools = new ToolRegistry([{ ...WriteTool, execute }]); + const canonicalTarget = await fs.realpath(target); + const originalStat = fs.stat; + let checks = 0; + const mock = vi.mocked(stat).mockImplementation(async (...args) => { + const value = await originalStat(...args); + if (String(args[0]) === canonicalTarget && ++checks === 3) controller.abort(); + return value; + }); + const input = { file_path: target, content: 'after' }; + try { + if (engine === 'bridge') + await expect( + createEngineToolRunner(options).run('Write', input, 'write'), + ).rejects.toThrow(); + else { + options.provider = { + name: 'test', + runTurn: async () => ({ + content: [{ type: 'tool_use', id: 'write', name: 'Write', input }], + stopReason: 'tool_use', + usage, + }), + }; + expect((await runAgent(options)).stopReason).toBe('aborted'); + } + expect(checks).toBe(3); + expect(execute).not.toHaveBeenCalled(); + expect(await readFile(target, 'utf8')).toBe('before'); + } finally { + mock.mockRestore(); + } + }, + ); + it.each(['native', 'bridge'])( + 'rechecks %s file targets after the approval wait', + async (engine) => { + const outside = join(root, 'outside'); + const inside = join(cwd, 'inside'); + await mkdir(outside); + await mkdir(inside); + await symlink(inside, join(cwd, 'link')); + options.mode = 'default'; + options.approval = async () => { + await unlink(join(cwd, 'link')); + await symlink(outside, join(cwd, 'link')); + return true; + }; + const input = { file_path: 'link/target', content: 'escape' }; + if (engine === 'bridge') + expect((await createEngineToolRunner(options).run('Write', input, 'write')).isError).toBe( + true, + ); + else { + let calls = 0; + options.provider = { + name: 'test', + runTurn: async () => + ++calls === 1 + ? { + content: [{ type: 'tool_use', id: 'write', name: 'Write', input }], + stopReason: 'tool_use', + usage, + } + : { content: [], stopReason: 'end_turn', usage }, + }; + const result = await runAgent(options); + expect(result.history.flatMap((m) => m.content)).toContainEqual( + expect.objectContaining({ type: 'tool_result', is_error: true }), + ); + } + await expect(access(join(outside, 'target'))).rejects.toThrow(); + }, + ); + it('rejects hard links to files outside the writable workspace', async () => { + const outside = join(root, 'secret'); + await writeFile(outside, 'unchanged'); + await link(outside, join(cwd, 'linked')); + expect( + ( + await createEngineToolRunner(options).run( + 'Write', + { file_path: 'linked', content: 'bad' }, + 'write', + ) + ).isError, + ).toBe(true); + expect(await readFile(outside, 'utf8')).toBe('unchanged'); + }); + it.each([ + ['native', 'default'], + ['bridge', 'default'], + ['native', 'workspace-write'], + ['bridge', 'workspace-write'], + ['native', 'read-only'], + ['bridge', 'read-only'], + ['native', 'denyWrite'], + ['bridge', 'denyWrite'], + ] as const)('honors additional directories in %s with %s sandbox', async (engine, mode) => { + const outside = join(root, 'additional'); + await mkdir(outside); + options.sandboxConfig = withAdditionalWritableDirs( + mode === 'default' + ? undefined + : mode === 'denyWrite' + ? { mode: 'workspace-write', filesystem: { denyWrite: [outside] } } + : { mode }, + [outside], + cwd, + ); + const input = { file_path: join(outside, 'target'), content: 'allowed' }; + if (engine === 'bridge') await createEngineToolRunner(options).run('Write', input, 'write'); + else { + let calls = 0; + options.provider = { + name: 'test', + runTurn: async () => + ++calls === 1 + ? { + content: [{ type: 'tool_use', id: 'write', name: 'Write', input }], + stopReason: 'tool_use', + usage, + } + : { content: [], stopReason: 'end_turn', usage }, + }; + await runAgent(options); + } + if (mode === 'read-only' || mode === 'denyWrite') + await expect(access(input.file_path)).rejects.toThrow(); + else expect(await readFile(input.file_path, 'utf8')).toBe('allowed'); + }); +}); diff --git a/packages/core/src/runtime/tool-runner.ts b/packages/core/src/runtime/tool-runner.ts new file mode 100644 index 00000000..98b0ee52 --- /dev/null +++ b/packages/core/src/runtime/tool-runner.ts @@ -0,0 +1,206 @@ +import type { RunAgentOptions } from '../agent.js'; +import { dispatchToolCall } from '../harness/tool-dispatcher.js'; +import { resolveRuntimePolicy } from './policy.js'; +import { applySpillPolicy } from '../spill/index.js'; +import { createLocalSpillStore } from '../spill/local.js'; +import { buildToolCallRecord, ledgerKindForTool, readPathFor } from '../ledger/record-tool-call.js'; +import { fileWriteDenial } from '../sandbox/file-policy.js'; +import type { ToolResult } from '../types.js'; + +/** The SDK bridge owns no permissions: every call is evaluated by the native policy. */ +export function createEngineToolRunner(options: RunAgentOptions & { onBlocked?: () => void }) { + const policy = resolveRuntimePolicy(options); + const reads = new Set(); + let blocked = false; + let queue: Promise = Promise.resolve(); + const spill = + options.spillStore ?? + (options.session + ? createLocalSpillStore(`${options.session.manager.root}/${options.session.id}`) + : undefined); + const nextSequence = async () => { + const snapshots = options.session + ? await options.session.manager.snapshots(options.session.id) + : []; + return snapshots.reduce((max, snapshot) => Math.max(max, snapshot.seq), 0) + 1; + }; + const execute = async ( + name: string, + input: Record, + id: string, + ): Promise => { + options.signal?.throwIfAborted(); + const handler = options.tools.get(name); + if (!handler || (options.allowedTools && !options.allowedTools.includes(name))) + return { content: 'Tool unavailable', isError: true }; + options.onEvent?.({ type: 'tool_use', id, name, input }); + const verdict = await dispatchToolCall({ + sandboxConfig: options.sandboxConfig, + sandboxDefaultMode: options.sandboxDefaultMode, + tool: name, + input, + cwd: options.cwd, + mode: policy.mode, + rules: policy.permissions, + contract: options.contract, + hooks: options.hooks, + autoMode: options.autoMode, + autoModeProvider: options.provider, + autoModeModel: options.model, + signal: options.signal, + }); + options.signal?.throwIfAborted(); + if ( + verdict.decision === 'ask' && + options.unattended && + options.onApprovalRequired === 'abort' + ) { + blocked = true; + const result = { + content: `Approval required for ${name}, but this run is unattended`, + isError: true, + }; + options.onEvent?.({ type: 'tool_result', id, result }); + options.onBlocked?.(); + return result; + } + let approved = verdict.decision === 'allow'; + if (verdict.decision === 'ask' && options.approval && !options.unattended) { + const response = await new Promise((resolve, reject) => { + const done = (value: boolean | 'always') => { + options.signal?.removeEventListener('abort', abort); + resolve(value); + }; + const abort = () => done(false); + options.signal?.addEventListener('abort', abort, { once: true }); + Promise.resolve() + .then(() => options.approval!(name, input, verdict)) + .then(done, (error: unknown) => { + options.signal?.removeEventListener('abort', abort); + reject(error); + }); + }); + approved = response === true || response === 'always'; + } + options.signal?.throwIfAborted(); + if (approved) { + const denial = await fileWriteDenial( + name, + input, + options.cwd, + options.sandboxConfig, + options.sandboxDefaultMode, + ); + options.signal?.throwIfAborted(); + if (denial) { + const result = { content: `Tool call blocked: ${denial}`, isError: true }; + options.onEvent?.({ type: 'tool_result', id, result }); + return result; + } + } + let result: ToolResult; + if (!approved) result = { content: `Tool call blocked: ${verdict.reason}`, isError: true }; + else { + const filePath = typeof input.file_path === 'string' ? input.file_path : undefined; + const mutation = ['Write', 'Edit', 'NotebookEdit'].includes(name); + const session = options.session; + const snapshot = async (reason: string) => { + if (!session || options.enableSnapshots === false || !filePath || !mutation) return; + return session.manager.snapshot({ + sessionId: session.id, + cwd: options.cwd, + filePath, + reason, + seq: await nextSequence(), + turnId: session.turnId, + }); + }; + let preSnapshot = await snapshot(`pre-${name}`); + if (session && name === 'Bash' && options.enableSnapshots !== false) + preSnapshot = await session.manager.gitCheckpoint({ + sessionId: session.id, + cwd: options.cwd, + reason: 'pre-Bash', + seq: await nextSequence(), + turnId: session.turnId, + }); + options.signal?.throwIfAborted(); + try { + const denial = await fileWriteDenial( + name, + input, + options.cwd, + options.sandboxConfig, + options.sandboxDefaultMode, + ); + if (denial) throw new Error(denial); + options.signal?.throwIfAborted(); + result = await handler.execute(input, { + cwd: options.cwd, + signal: options.signal, + sandboxConfig: options.sandboxConfig, + sandboxDefaultMode: options.sandboxDefaultMode ?? 'workspace-write', + contract: options.contract, + askUser: options.askUser, + sessionId: session?.id, + sessionDir: session ? `${session.manager.root}/${session.id}` : undefined, + turnId: session?.turnId, + }); + } catch (error) { + result = { content: `Tool failed: ${(error as Error).message}`, isError: true }; + } + options.signal?.throwIfAborted(); + if (!result.isError) await snapshot(`post-${name}`); + const readPath = readPathFor(name, input); + if (!result.isError && readPath) reads.add(readPath); + await options.hooks?.dispatch({ + event: 'PostToolUse', + cwd: options.cwd, + signal: options.signal, + triggeredAt: new Date().toISOString(), + payload: { + tool: name, + input, + result_content: result.content.slice(0, 1000), + is_error: result.isError ?? false, + }, + }); + const kind = ledgerKindForTool(name); + options.signal?.throwIfAborted(); + if (kind && !result.isError && options.ledger) { + const record = buildToolCallRecord({ + tool: name, + input, + cwd: options.cwd, + intent: options.userMessage, + threadId: session?.id, + turnId: session?.turnId, + snapshotSeq: preSnapshot?.seq, + readPaths: [...reads], + }); + if (record) await options.ledger.append(kind, record).catch(() => undefined); + } + } + options.signal?.throwIfAborted(); + result = await applySpillPolicy(result, { + source: { toolName: name, callId: id, label: 'result' }, + store: spill, + thresholdChars: options.spillThresholdChars, + }); + options.onEvent?.({ type: 'tool_result', id, result }); + return result; + }; + return { + get blocked() { + return blocked; + }, + run(name: string, input: Record, id: string) { + const next = queue.then(() => execute(name, input, id)); + queue = next.catch(() => undefined); + return next; + }, + async drain() { + await queue; + }, + }; +} diff --git a/packages/core/src/sandbox/additional-dirs.test.ts b/packages/core/src/sandbox/additional-dirs.test.ts index 93ee237b..14408943 100644 --- a/packages/core/src/sandbox/additional-dirs.test.ts +++ b/packages/core/src/sandbox/additional-dirs.test.ts @@ -33,7 +33,21 @@ describe('withAdditionalWritableDirs', () => { it('is a no-op when the sandbox is disabled — never silently enables it', () => { const off: SandboxConfig = { enabled: false }; expect(withAdditionalWritableDirs(off, ['/data'])).toBe(off); - expect(withAdditionalWritableDirs(undefined, ['/data'])).toBeUndefined(); + }); + + it.each([ + undefined, + { mode: 'workspace-write' }, + { enabled: false, mode: 'workspace-write' }, + ] as const)('honors additional directories under the resolved workspace mode: %j', (config) => { + expect(withAdditionalWritableDirs(config, ['/data'])?.filesystem?.allowWrite).toEqual([ + '/data', + ]); + }); + + it('does not widen an explicit read-only mode even when legacy enabled is true', () => { + const config: SandboxConfig = { enabled: true, mode: 'read-only' }; + expect(withAdditionalWritableDirs(config, ['/data'])).toBe(config); }); it('is a no-op for empty/undefined directory lists', () => { diff --git a/packages/core/src/sandbox/additional-dirs.ts b/packages/core/src/sandbox/additional-dirs.ts index b9670b75..ea9d705a 100644 --- a/packages/core/src/sandbox/additional-dirs.ts +++ b/packages/core/src/sandbox/additional-dirs.ts @@ -1,20 +1,19 @@ // Folds `permissions.additionalDirectories` into the sandbox's writable roots. // -// The file tools (Read/Write/Edit/Glob/Grep) already accept any absolute path — -// there is no cwd containment to widen. The only thing that actually restricts -// writes is the OS sandbox wrapping Bash, so "enforcing /add-dir" means adding -// those directories to `filesystem.allowWrite`. +// Both direct file mutations and the OS sandbox wrapping Bash consume +// `filesystem.allowWrite`, including hosts using the default workspace mode. // // Every host that builds a SandboxConfig must route through this, or the // setting is enforced in some clients and silently ignored in others. import { isAbsolute, resolve } from 'node:path'; import type { SandboxConfig } from '../config/types.js'; +import { resolveSandboxMode } from './policy.js'; /** * Return a copy of `sandbox` whose `filesystem.allowWrite` also contains - * `dirs`. Never mutates its input. A no-op when the sandbox is disabled or - * `dirs` is empty, so hosts can call it unconditionally. + * `dirs`. Never mutates its input. Only workspace-write widens writable roots; + * read-only and danger-full-access keep their existing semantics. * * Relative entries are resolved against `cwd` when provided; entries that are * still not absolute are dropped rather than being handed to the sandbox @@ -25,7 +24,7 @@ export function withAdditionalWritableDirs( dirs: readonly string[] | undefined, cwd?: string, ): SandboxConfig | undefined { - if (!sandbox?.enabled) return sandbox; + if (resolveSandboxMode(sandbox) !== 'workspace-write') return sandbox; if (!dirs || dirs.length === 0) return sandbox; const normalized = dirs @@ -33,12 +32,12 @@ export function withAdditionalWritableDirs( .filter((dir): dir is string => dir !== undefined); if (normalized.length === 0) return sandbox; - const existing = sandbox.filesystem?.allowWrite ?? []; + const existing = sandbox?.filesystem?.allowWrite ?? []; const merged = [...new Set([...existing, ...normalized])]; if (merged.length === existing.length) return sandbox; return { ...sandbox, - filesystem: { ...sandbox.filesystem, allowWrite: merged }, + filesystem: { ...sandbox?.filesystem, allowWrite: merged }, }; } diff --git a/packages/core/src/sandbox/file-policy.ts b/packages/core/src/sandbox/file-policy.ts new file mode 100644 index 00000000..0f5ef3ce --- /dev/null +++ b/packages/core/src/sandbox/file-policy.ts @@ -0,0 +1,52 @@ +import { realpath, stat } from 'node:fs/promises'; +import { dirname, isAbsolute, relative, resolve } from 'node:path'; +import type { SandboxConfig, SandboxMode } from '../config/types.js'; +import { resolveSandboxMode } from './policy.js'; + +async function resolvedTarget(path: string): Promise { + try { + return await realpath(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + const parent = dirname(path); + if (parent === path) throw error; + return resolve(await resolvedTarget(parent), relative(parent, path)); + } +} +function contains(root: string, path: string) { + const rel = relative(root, path); + return rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith('../')); +} +export async function fileWriteDenial( + tool: string, + input: Record, + cwd: string, + config?: SandboxConfig, + fallback?: SandboxMode, +): Promise { + if (!['Write', 'Edit', 'NotebookEdit'].includes(tool)) return; + const mode = resolveSandboxMode(config, fallback); + if (mode === 'danger-full-access') return; + if (mode === 'read-only') return 'The sandbox is read-only'; + const path = tool === 'NotebookEdit' ? input.notebook_path : input.file_path; + if (typeof path !== 'string' || !path) return 'A file path is required'; + try { + const target = await resolvedTarget(resolve(cwd, path)); + try { + if ((await stat(target)).nlink > 1) return 'Refusing to write a hard-linked file'; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + for (const root of config?.filesystem?.denyWrite ?? []) { + if (contains(await resolvedTarget(resolve(cwd, root)), target)) + return 'File write is excluded by sandbox.filesystem.denyWrite'; + } + for (const root of [cwd, ...(config?.filesystem?.allowWrite ?? [])]) { + const resolvedRoot = await resolvedTarget(resolve(cwd, root)); + if (contains(resolvedRoot, target)) return; + } + return 'File write escapes the writable workspace (including symlinks)'; + } catch { + return 'Cannot verify the file write boundary'; + } +} diff --git a/packages/core/src/sessions/manager.ts b/packages/core/src/sessions/manager.ts index 90406662..a7aaeceb 100644 --- a/packages/core/src/sessions/manager.ts +++ b/packages/core/src/sessions/manager.ts @@ -2,6 +2,8 @@ // Spec: docs/DEVELOPMENT_PLAN.md §3.5 import type { StoredMessage } from '../types.js'; +import { access, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; import { appendMessage, defaultSessionsDir, @@ -65,6 +67,30 @@ export class SessionManager { return listSessionsLow(this.root); } + /** UI projections cannot substitute for an engine's private resumable history. */ + async assertForkable(sessionId: string): Promise { + if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(sessionId)) throw new Error('Invalid session ID'); + const directory = join(this.root, sessionId); + try { + const binding = JSON.parse(await readFile(join(directory, 'engine.json'), 'utf8')) as { + version: number; + engine: string; + }; + if (binding.version !== 1 || binding.engine !== 'native') + throw new Error('This engine session cannot be forked. Start a new session instead.'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + // Fail closed if an SDK session has lost its engine binding. + try { + await access(join(directory, 'claude-sdk')); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return; + throw error; + } + throw new Error('SDK session history cannot be forked. Start a new session instead.'); + } + async delete(sessionId: string): Promise { await deleteSession(this.root, sessionId); } diff --git a/packages/core/src/tools/ask-user.test.ts b/packages/core/src/tools/ask-user.test.ts index 224a6388..0ef01c14 100644 --- a/packages/core/src/tools/ask-user.test.ts +++ b/packages/core/src/tools/ask-user.test.ts @@ -2,6 +2,21 @@ import { describe, expect, it } from 'vitest'; import { AskUserQuestionTool } from './ask-user.js'; describe('AskUserQuestionTool', () => { + it('cancels a host prompt that never resolves', async () => { + const ac = new AbortController(); + const pending = AskUserQuestionTool.execute( + { question: 'Wait?' }, + { + cwd: '/x', + signal: ac.signal, + askUser: async () => { + ac.abort(); + return new Promise(() => {}); + }, + }, + ); + expect((await pending).isError).toBe(true); + }); it('errors when no askUser callback is provided', async () => { const r = await AskUserQuestionTool.execute( { question: 'Which library?', options: [{ label: 'A' }, { label: 'B' }] }, diff --git a/packages/core/src/tools/ask-user.ts b/packages/core/src/tools/ask-user.ts index 7a1b882f..1eca1d62 100644 --- a/packages/core/src/tools/ask-user.ts +++ b/packages/core/src/tools/ask-user.ts @@ -74,10 +74,34 @@ export const AskUserQuestionTool: ToolHandler = { return { content: 'Error: at most 4 options allowed.', isError: true }; } try { - const answer = await ctx.askUser({ + ctx.signal?.throwIfAborted(); + const request = { question: input.question, options, multiSelect: !!input.multiSelect, + }; + const answer = await new Promise((resolve, reject) => { + const cleanup = () => ctx.signal?.removeEventListener('abort', abort); + const abort = () => { + cleanup(); + reject(ctx.signal?.reason ?? new Error('User prompt cancelled')); + }; + ctx.signal?.addEventListener('abort', abort, { once: true }); + Promise.resolve() + .then(() => { + ctx.signal?.throwIfAborted(); + return ctx.askUser!(request); + }) + .then( + (answer) => { + cleanup(); + resolve(answer); + }, + (error: unknown) => { + cleanup(); + reject(error); + }, + ); }); return { content: answer, data: { question: input.question, answer } }; } catch (err) { diff --git a/packages/core/src/tools/edit.test.ts b/packages/core/src/tools/edit.test.ts index ff028042..f96d7bb2 100644 --- a/packages/core/src/tools/edit.test.ts +++ b/packages/core/src/tools/edit.test.ts @@ -2,7 +2,7 @@ import { promises as fs } from 'node:fs'; import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { EditTool } from './edit.js'; describe('EditTool', () => { @@ -14,6 +14,28 @@ describe('EditTool', () => { await rm(tmp, { recursive: true, force: true }); }); + it('does not write when cancelled while reading the original file', async () => { + const path = join(tmp, 'cancelled.txt'); + await fs.writeFile(path, 'before'); + const controller = new AbortController(); + const originalReadFile = fs.readFile; + const mock = vi.spyOn(fs, 'readFile').mockImplementation(async (...args) => { + const result = await originalReadFile(...args); + controller.abort(); + return result; + }); + try { + const result = await EditTool.execute( + { file_path: path, old_string: 'before', new_string: 'after' }, + { cwd: tmp, signal: controller.signal }, + ); + expect(result.isError).toBe(true); + expect(await originalReadFile(path, 'utf8')).toBe('before'); + } finally { + mock.mockRestore(); + } + }); + it('replaces a unique occurrence', async () => { const path = join(tmp, 'a.txt'); await fs.writeFile(path, 'hello world\nbye world'); diff --git a/packages/core/src/tools/edit.ts b/packages/core/src/tools/edit.ts index d5ec775b..1a96c3cc 100644 --- a/packages/core/src/tools/edit.ts +++ b/packages/core/src/tools/edit.ts @@ -56,7 +56,8 @@ export const EditTool: ToolHandler = { let raw: string; try { - raw = await fs.readFile(absPath, 'utf8'); + ctx.signal?.throwIfAborted(); + raw = await fs.readFile(absPath, { encoding: 'utf8', signal: ctx.signal }); } catch (err) { const e = err as NodeJS.ErrnoException; return { content: `Error reading ${absPath}: ${e.message}`, isError: true }; @@ -81,7 +82,8 @@ export const EditTool: ToolHandler = { : raw.replace(input.old_string, input.new_string); try { - await fs.writeFile(absPath, next, 'utf8'); + ctx.signal?.throwIfAborted(); + await fs.writeFile(absPath, next, { encoding: 'utf8', signal: ctx.signal }); } catch (err) { return { content: `Error writing ${absPath}: ${(err as Error).message}`, isError: true }; } diff --git a/packages/core/src/tools/notebook.test.ts b/packages/core/src/tools/notebook.test.ts index 08f17bf6..e19fab3e 100644 --- a/packages/core/src/tools/notebook.test.ts +++ b/packages/core/src/tools/notebook.test.ts @@ -1,7 +1,8 @@ +import { promises as fs } from 'node:fs'; import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { NotebookEditTool } from './notebook.js'; interface NbCell { @@ -38,6 +39,26 @@ describe('NotebookEditTool', () => { await rm(cwd, { recursive: true, force: true }); }); + it('does not write when cancelled while reading the notebook', async () => { + const controller = new AbortController(); + const originalRead = fs.readFile; + const mock = vi.spyOn(fs, 'readFile').mockImplementation(async (...args) => { + const result = await originalRead(...args); + controller.abort(); + return result; + }); + try { + const result = await NotebookEditTool.execute( + { notebook_path: nbName, cell_id: 'c1', new_source: 'changed' }, + { cwd, signal: controller.signal }, + ); + expect(result.isError).toBe(true); + expect(await originalRead(join(cwd, nbName), 'utf8')).toBe(makeNb()); + } finally { + mock.mockRestore(); + } + }); + it('replaces a cell by id (source stored as nbformat lines)', async () => { const r = await NotebookEditTool.execute( { notebook_path: nbName, cell_id: 'c1', new_source: 'print(2)\nprint(3)' }, diff --git a/packages/core/src/tools/notebook.ts b/packages/core/src/tools/notebook.ts index 0f1e8e00..ba41bf22 100644 --- a/packages/core/src/tools/notebook.ts +++ b/packages/core/src/tools/notebook.ts @@ -92,7 +92,10 @@ export const NotebookEditTool: ToolHandler = { let nb: Notebook; try { - nb = JSON.parse(await fs.readFile(absPath, 'utf8')) as Notebook; + ctx.signal?.throwIfAborted(); + nb = JSON.parse( + await fs.readFile(absPath, { encoding: 'utf8', signal: ctx.signal }), + ) as Notebook; } catch (err) { return { content: `Error reading notebook: ${(err as Error).message}`, isError: true }; } @@ -134,7 +137,11 @@ export const NotebookEditTool: ToolHandler = { } try { - await fs.writeFile(absPath, JSON.stringify(nb, null, 1) + '\n', 'utf8'); + ctx.signal?.throwIfAborted(); + await fs.writeFile(absPath, JSON.stringify(nb, null, 1) + '\n', { + encoding: 'utf8', + signal: ctx.signal, + }); } catch (err) { return { content: `Error writing notebook: ${(err as Error).message}`, isError: true }; } diff --git a/packages/core/src/tools/restore-review-action.test.ts b/packages/core/src/tools/restore-review-action.test.ts index a2f7b9d2..d82b2731 100644 --- a/packages/core/src/tools/restore-review-action.test.ts +++ b/packages/core/src/tools/restore-review-action.test.ts @@ -46,6 +46,35 @@ describe('RestoreReviewActionTool', () => { expect.objectContaining({ reason: 'post-RestoreReviewAction', turnId: 'turn-revert' }), ]); }); + it.each(['read-only', 'denyWrite', 'contract'] as const)( + 'honors %s restrictions on the discovered restore footprint', + async (restriction) => { + const filePath = join(cwd, 'a.txt'); + await fs.writeFile(filePath, 'before'); + await snapshot(filePath, 'pre-Write', 1); + await fs.writeFile(filePath, 'after'); + await snapshot(filePath, 'post-Write', 2); + const result = await RestoreReviewActionTool.execute( + { action_turn_id: actionTurnId }, + { + cwd, + sessionDir: join(sessionsRoot, sessionId), + sandboxConfig: + restriction === 'read-only' + ? { mode: 'read-only' } + : restriction === 'denyWrite' + ? { filesystem: { denyWrite: ['a.txt'] } } + : undefined, + contract: + restriction === 'contract' + ? { version: 1, rules: [{ glob: 'a.txt', write: 'deny' }] } + : undefined, + }, + ); + expect(result.isError).toBe(true); + expect(await fs.readFile(filePath, 'utf8')).toBe('after'); + }, + ); it('refuses atomically when the file changed after the action', async () => { const filePath = join(cwd, 'a.txt'); diff --git a/packages/core/src/tools/restore-review-action.ts b/packages/core/src/tools/restore-review-action.ts index f3675821..e0ba02cc 100644 --- a/packages/core/src/tools/restore-review-action.ts +++ b/packages/core/src/tools/restore-review-action.ts @@ -4,6 +4,8 @@ import { basename, dirname, isAbsolute, relative, resolve } from 'node:path'; import { captureSnapshot, listSnapshots, type Snapshot } from '../sessions/snapshots.js'; import type { ToolContext, ToolHandler, ToolResult } from '../types.js'; +import { fileWriteDenial } from '../sandbox/file-policy.js'; +import { evaluateContract } from '../config/contract-dispatch.js'; interface RestoreCandidate { filePath: string; @@ -55,6 +57,27 @@ export const RestoreReviewActionTool: ToolHandler = { if (candidates.length === 0) { return failure('no complete Edit/Write snapshot pairs were found for this action'); } + // Revert discovers its write footprint from snapshots, after dispatch. + // It must honor the same path restrictions as an explicit Write call. + for (const candidate of candidates) { + ctx.signal?.throwIfAborted(); + const input = { file_path: candidate.filePath }; + const denial = await fileWriteDenial( + 'Write', + input, + ctx.cwd, + ctx.sandboxConfig, + ctx.sandboxDefaultMode, + ); + if (denial) return failure(denial); + const verdict = evaluateContract(ctx.contract, { + tool: 'Write', + input, + cwd: ctx.cwd, + }).verdict; + if (verdict === 'deny' || verdict === 'ask') + return failure('file contract restricts the restore target'); + } let sequence = snapshots.reduce((maximum, snapshot) => Math.max(maximum, snapshot.seq), 0); for (const candidate of candidates) { @@ -72,6 +95,7 @@ export const RestoreReviewActionTool: ToolHandler = { const restored: RestoreCandidate[] = []; try { for (const candidate of candidates) { + ctx.signal?.throwIfAborted(); await restoreBeforeImage(candidate); restored.push(candidate); } diff --git a/packages/core/src/tools/write.test.ts b/packages/core/src/tools/write.test.ts index 93294eeb..98249356 100644 --- a/packages/core/src/tools/write.test.ts +++ b/packages/core/src/tools/write.test.ts @@ -2,7 +2,7 @@ import { promises as fs } from 'node:fs'; import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { WriteTool } from './write.js'; describe('WriteTool', () => { @@ -14,6 +14,28 @@ describe('WriteTool', () => { await rm(tmp, { recursive: true, force: true }); }); + it('does not write when cancelled during parent-directory preparation', async () => { + const path = join(tmp, 'cancelled.txt'); + await fs.writeFile(path, 'before'); + const controller = new AbortController(); + const originalMkdir = fs.mkdir; + const mock = vi.spyOn(fs, 'mkdir').mockImplementation(async (...args) => { + const result = await originalMkdir(...args); + controller.abort(); + return result; + }); + try { + const result = await WriteTool.execute( + { file_path: path, content: 'after' }, + { cwd: tmp, signal: controller.signal }, + ); + expect(result.isError).toBe(true); + expect(await fs.readFile(path, 'utf8')).toBe('before'); + } finally { + mock.mockRestore(); + } + }); + it('writes a new file', async () => { const path = join(tmp, 'hello.txt'); const r = await WriteTool.execute({ file_path: path, content: 'hi\n' }, { cwd: tmp }); diff --git a/packages/core/src/tools/write.ts b/packages/core/src/tools/write.ts index 58ee1159..44d65e9f 100644 --- a/packages/core/src/tools/write.ts +++ b/packages/core/src/tools/write.ts @@ -40,8 +40,10 @@ export const WriteTool: ToolHandler = { : resolve(ctx.cwd, input.file_path); try { + ctx.signal?.throwIfAborted(); await fs.mkdir(dirname(absPath), { recursive: true }); - await fs.writeFile(absPath, input.content, 'utf8'); + ctx.signal?.throwIfAborted(); + await fs.writeFile(absPath, input.content, { encoding: 'utf8', signal: ctx.signal }); } catch (err) { const e = err as Error; return { content: `Error writing ${absPath}: ${e.message}`, isError: true }; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index ddbf3bd3..5d7f39d3 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -77,6 +77,9 @@ export interface ToolResultBlock { } export interface ThinkingBlock { + /** Scope continuation data to the producing model; never replay across providers. */ + provider?: string; + model?: string; type: 'thinking'; text: string; } @@ -220,7 +223,8 @@ export type AgentEvent = /** The whole user turn reached one terminal state. Emitted exactly once. */ | { type: 'turn_complete'; - stopReason: 'end_turn' | 'max_turns' | 'aborted' | 'error' | 'blocked'; + stopReason: + 'end_turn' | 'stop_sequence' | 'max_tokens' | 'max_turns' | 'aborted' | 'error' | 'blocked'; message?: StoredMessage; } | { diff --git a/packages/protocol/src/runtime.ts b/packages/protocol/src/runtime.ts index d94c2777..23f3c3af 100644 --- a/packages/protocol/src/runtime.ts +++ b/packages/protocol/src/runtime.ts @@ -20,6 +20,8 @@ function clone(value: T): T { export interface ThreadStore { load(threadId: string): Promise; save(thread: ThreadSnapshot): Promise; + /** Stores with engine-private history must validate before copying a projection. */ + assertForkable?(threadId: string): Promise; /** * Optional: listing and archiving need an index the base store doesn't have. * A store without them leaves `threadManagement` off, and the runtime rejects @@ -219,6 +221,7 @@ export class ProtocolRuntime { */ async forkThread(threadId: string, traceId?: string): Promise { const source = await this.requireThread(threadId); + await this.options.store.assertForkable?.(threadId); const now = this.now(); const fork: ThreadSnapshot = { id: this.newId('thread'), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f539383..a2f6adeb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,7 +31,7 @@ importers: version: 8.68.0(eslint@10.9.1(jiti@1.21.7))(typescript@5.9.3) vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@22.19.19)(vite@8.2.1(@types/node@22.19.19)(esbuild@0.21.5)(jiti@1.21.7)) + version: 4.1.11(@opentelemetry/api@1.10.0)(@types/node@22.19.19)(vite@8.2.1(@types/node@22.19.19)(esbuild@0.21.5)(jiti@1.21.7)) apps/cli: dependencies: @@ -53,7 +53,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@22.19.19)(vite@8.2.1(@types/node@22.19.19)(esbuild@0.21.5)(jiti@1.21.7)) + version: 4.1.11(@opentelemetry/api@1.10.0)(@types/node@22.19.19)(vite@8.2.1(@types/node@22.19.19)(esbuild@0.21.5)(jiti@1.21.7)) apps/desktop: dependencies: @@ -117,7 +117,7 @@ importers: version: 8.2.1(@types/node@22.19.19)(esbuild@0.21.5)(jiti@1.21.7) vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@22.19.19)(vite@8.2.1(@types/node@22.19.19)(esbuild@0.21.5)(jiti@1.21.7)) + version: 4.1.11(@opentelemetry/api@1.10.0)(@types/node@22.19.19)(vite@8.2.1(@types/node@22.19.19)(esbuild@0.21.5)(jiti@1.21.7)) apps/lsp: dependencies: @@ -139,7 +139,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@22.19.19)(vite@8.2.1(@types/node@22.19.19)(esbuild@0.21.5)(jiti@1.21.7)) + version: 4.1.11(@opentelemetry/api@1.10.0)(@types/node@22.19.19)(vite@8.2.1(@types/node@22.19.19)(esbuild@0.21.5)(jiti@1.21.7)) apps/server: dependencies: @@ -149,6 +149,13 @@ importers: '@deepcode/protocol': specifier: workspace:* version: link:../../packages/protocol + '@modelcontextprotocol/sdk': + specifier: ^1.30.0 + version: 1.30.0(zod@4.4.3) + optionalDependencies: + '@anthropic-ai/claude-agent-sdk': + specifier: 0.3.310 + version: 0.3.310(@anthropic-ai/sdk@1.3.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(zod@4.4.3) devDependencies: '@types/node': specifier: ^22.10.0 @@ -161,7 +168,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@22.19.19)(vite@8.2.1(@types/node@22.19.19)(esbuild@0.21.5)(jiti@1.21.7)) + version: 4.1.11(@opentelemetry/api@1.10.0)(@types/node@22.19.19)(vite@8.2.1(@types/node@22.19.19)(esbuild@0.21.5)(jiti@1.21.7)) apps/vscode: dependencies: @@ -189,7 +196,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@22.19.19)(vite@8.2.1(@types/node@22.19.19)(esbuild@0.21.5)(jiti@1.21.7)) + version: 4.1.11(@opentelemetry/api@1.10.0)(@types/node@22.19.19)(vite@8.2.1(@types/node@22.19.19)(esbuild@0.21.5)(jiti@1.21.7)) packages/core: dependencies: @@ -208,7 +215,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@22.19.19)(vite@8.2.1(@types/node@22.19.19)(esbuild@0.21.5)(jiti@1.21.7)) + version: 4.1.11(@opentelemetry/api@1.10.0)(@types/node@22.19.19)(vite@8.2.1(@types/node@22.19.19)(esbuild@0.21.5)(jiti@1.21.7)) packages/protocol: devDependencies: @@ -217,7 +224,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@22.19.19)(vite@8.2.1(@types/node@22.19.19)(esbuild@0.21.5)(jiti@1.21.7)) + version: 4.1.11(@opentelemetry/api@1.10.0)(@types/node@22.19.19)(vite@8.2.1(@types/node@22.19.19)(esbuild@0.21.5)(jiti@1.21.7)) packages/shared-ui: devDependencies: @@ -227,6 +234,63 @@ importers: packages: + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.310': + resolution: {integrity: sha512-gpzMjDZdcdA2Webuqm9j76exG8yNK/755ZYWbS3MM4vVBlAsm+enEO+hhNt0Mx1VMil28RWetvTO4ZJ3YODVMA==} + cpu: [arm64] + os: [darwin] + + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.310': + resolution: {integrity: sha512-ZRQ4ZrzE2OOfR+Fx8h+CM4NKh0UiYqlh5TowP7HUhn5KUoYMjoSR/xIhix8ow42X2IDiBy0FjEVaA/Awhu2KAw==} + cpu: [x64] + os: [darwin] + + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.310': + resolution: {integrity: sha512-viXuvvtli3pfHzTEI+oeJzk3nldkfHvj757Xh/uAeximUT9ANZIq4SMZw2cSqbhASjni0qcO6B1x4BGx2LuQpw==} + cpu: [arm64] + os: [linux] + + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.310': + resolution: {integrity: sha512-l6SPgUmJknjx+6/Pb7OR353fm3xBNAWqodFoC/Cflg0yMcLfz9iwNIc3Drgos2DqfHnid+ODqDzSO93AgQ7RmA==} + cpu: [arm64] + os: [linux] + + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.310': + resolution: {integrity: sha512-/qFgN5LcofB8PayxBXoa+xDxkYPPQeo/eCyN0DsyyeI67ifH/l49pz9J+sH0+ctDe9und0vMCJPzng70bx2K+Q==} + cpu: [x64] + os: [linux] + + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.310': + resolution: {integrity: sha512-jx7awHPoofXuE6cRRQSzLUFyW6aUXbYD9AtnNIDUHmmPPrgJ9nSnqjPaqcNhOdaZH7onUUujEAq26N2tx29fYg==} + cpu: [x64] + os: [linux] + + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.310': + resolution: {integrity: sha512-tRmDMhZpoWAIrZxFBTh6vyZB8W5gldU5/Rfo+cyq/OvBKTxxbim+/0tIa9QlJQncoVLXVN9eySYeeB9KMjio7w==} + cpu: [arm64] + os: [win32] + + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.310': + resolution: {integrity: sha512-zvrIApxPccTJc6gCOtw5hj27QjWhhTi3z5x5iMmf9f0LZAKR+9mrgENVeFL7PP7zRGdYVhhX2SahbzxN83L/eA==} + cpu: [x64] + os: [win32] + + '@anthropic-ai/claude-agent-sdk@0.3.310': + resolution: {integrity: sha512-fo+sr+9Ks73P001f6XKRQlyzfIBAJMgXArLUkH5Tu98NikRDZeGtFQVj23d5/hj2ijpFgzffrr4fHywY9qUkoQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@anthropic-ai/sdk': '>=0.93.0' + '@modelcontextprotocol/sdk': ^1.29.0 + zod: ^4.0.0 + + '@anthropic-ai/sdk@1.3.0': + resolution: {integrity: sha512-k7z9BlLLPxfZH+ov7dS6EnaN/wiZeV8YNT1LZfrpAHIy7oY7pHr4UzirF9f2OlHtFEnRBOX+AZQ91Y5fK+TPTw==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + '@azu/format-text@1.0.2': resolution: {integrity: sha512-Swi4N7Edy1Eqq82GxgEECXSSLyn6GOb5htRFPzBDdUkECGXtlf12ynO5oJSpWKPwCaUssOu7NfhDcCWpIC6Ywg==} @@ -285,6 +349,10 @@ packages: resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} + '@babel/runtime@7.29.10': + resolution: {integrity: sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==} + engines: {node: '>=6.9.0'} + '@esbuild/aix-ppc64@0.21.5': resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} engines: {node: '>=12'} @@ -513,6 +581,10 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@opentelemetry/api@1.10.0': + resolution: {integrity: sha512-lMkyes8iHrG+mk6WH7X70MdCLI2RS0FwNBY1PyQ8UemaijE2m5mv5A0Niy3HtbYTFrgkUEQjFMXmSAk2hQDuPQ==} + engines: {node: '>=8.0.0'} + '@oxc-project/types@0.143.0': resolution: {integrity: sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==} @@ -657,6 +729,9 @@ packages: resolution: {integrity: sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==} engines: {node: '>=18'} + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -1381,6 +1456,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + fast-uri@3.1.5: resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==} @@ -1635,6 +1713,10 @@ packages: json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + json-schema-to-ts@3.1.1: + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} + engines: {node: '>=16'} + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} @@ -2262,6 +2344,9 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standardwebhooks@1.1.1: + resolution: {integrity: sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -2348,6 +2433,9 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + ts-algebra@2.0.0: + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} @@ -2586,6 +2674,55 @@ packages: snapshots: + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.310': + optional: true + + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.310': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.310': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.310': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.310': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.310': + optional: true + + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.310': + optional: true + + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.310': + optional: true + + '@anthropic-ai/claude-agent-sdk@0.3.310(@anthropic-ai/sdk@1.3.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(zod@4.4.3)': + dependencies: + '@anthropic-ai/sdk': 1.3.0(zod@4.4.3) + '@modelcontextprotocol/sdk': 1.30.0(zod@4.4.3) + zod: 4.4.3 + optionalDependencies: + '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.310 + '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.310 + '@anthropic-ai/claude-agent-sdk-linux-arm64': 0.3.310 + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.3.310 + '@anthropic-ai/claude-agent-sdk-linux-x64': 0.3.310 + '@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.3.310 + '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.310 + '@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.310 + optional: true + + '@anthropic-ai/sdk@1.3.0(zod@4.4.3)': + dependencies: + '@opentelemetry/api': 1.10.0 + json-schema-to-ts: 3.1.1 + standardwebhooks: 1.1.1 + optionalDependencies: + zod: 4.4.3 + optional: true + '@azu/format-text@1.0.2': {} '@azu/style-format@1.0.1': @@ -2682,6 +2819,9 @@ snapshots: '@babel/helper-validator-identifier@7.29.7': {} + '@babel/runtime@7.29.10': + optional: true + '@esbuild/aix-ppc64@0.21.5': optional: true @@ -2841,6 +2981,9 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 + '@opentelemetry/api@1.10.0': + optional: true + '@oxc-project/types@0.143.0': {} '@playwright/test@1.62.1': @@ -2967,6 +3110,9 @@ snapshots: '@sindresorhus/merge-streams@2.3.0': {} + '@stablelib/base64@1.0.1': + optional: true + '@standard-schema/spec@1.1.0': {} '@tauri-apps/api@2.11.1': {} @@ -3795,6 +3941,9 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-sha256@1.3.0: + optional: true + fast-uri@3.1.5: {} fastq@1.20.1: @@ -4041,6 +4190,12 @@ snapshots: json-buffer@3.0.1: {} + json-schema-to-ts@3.1.1: + dependencies: + '@babel/runtime': 7.29.10 + ts-algebra: 2.0.0 + optional: true + json-schema-traverse@0.4.1: {} json-schema-traverse@1.0.0: {} @@ -4665,6 +4820,12 @@ snapshots: stackback@0.0.2: {} + standardwebhooks@1.1.1: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + optional: true + statuses@2.0.2: {} std-env@4.2.0: {} @@ -4759,6 +4920,9 @@ snapshots: toidentifier@1.0.1: {} + ts-algebra@2.0.0: + optional: true + ts-api-utils@2.5.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -4850,7 +5014,7 @@ snapshots: fsevents: 2.3.3 jiti: 1.21.7 - vitest@4.1.11(@types/node@22.19.19)(vite@8.2.1(@types/node@22.19.19)(esbuild@0.21.5)(jiti@1.21.7)): + vitest@4.1.11(@opentelemetry/api@1.10.0)(@types/node@22.19.19)(vite@8.2.1(@types/node@22.19.19)(esbuild@0.21.5)(jiti@1.21.7)): dependencies: '@vitest/expect': 4.1.11 '@vitest/mocker': 4.1.11(vite@8.2.1(@types/node@22.19.19)(esbuild@0.21.5)(jiti@1.21.7)) @@ -4873,6 +5037,7 @@ snapshots: vite: 8.2.1(@types/node@22.19.19)(esbuild@0.21.5)(jiti@1.21.7) why-is-node-running: 2.3.0 optionalDependencies: + '@opentelemetry/api': 1.10.0 '@types/node': 22.19.19 transitivePeerDependencies: - msw