mirror of
https://github.com/garrytan/gstack.git
synced 2026-06-20 00:30:10 +02:00
c7ae63201a
* feat: add shared call-time isConductor() helper
Single source of truth for Conductor host detection in TS consumers
(CONDUCTOR_WORKSPACE_PATH / CONDUCTOR_PORT). Reads the passed env at
call time, not a module-load snapshot, so unit tests can pin the env
inline without Bun --preload (esm-hoist-breaks-env-pin-bootstrap).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test: harden question-preference-hook harness against ambient Conductor env
runHook copied all of process.env into the hook subprocess, so running the
suite inside Conductor (CONDUCTOR_WORKSPACE_PATH/PORT set) would leak those
markers. Strip them so the existing cases deterministically characterize
NON-Conductor behavior before the Conductor branch lands. Baseline: 15 pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat: PreToolUse hook denies AskUserQuestion in Conductor, redirects to prose
Conductor disables native AskUserQuestion and routes through a flaky MCP
variant that returns '[Tool result missing due to internal error]'. The
hook now denies any AUQ call in a Conductor session and instructs the model
to render a prose decision brief instead (transport avoidance, not preference
enforcement) — firing for one-way doors too, with a typed-confirmation
requirement for destructive paths.
Precedence: never-ask auto-decide still wins (user already settled those);
Conductor prose is the fallback for everything else; non-Conductor behavior
is byte-for-byte unchanged. Restructured the per-question loop to compute
eligibility without early-returning so the Conductor branch can run as the
fallback while preserving memoryContext on every exit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat: Conductor renders AskUserQuestion decisions as prose by default
In Conductor, native AskUserQuestion is disabled and the MCP variant is
flaky, so skills now render every decision as a plain-text prose brief the
user answers by typing a letter — proactively, not as a failure reaction.
- Preamble emits CONDUCTOR_SESSION, gated on != headless so eval/CI inside
Conductor still BLOCKs instead of rendering prose to nobody.
- AskUserQuestion Format gains a Conductor-default-prose rule (auto-decide
preferences still apply first; prose decisions log via gstack-question-log
since PostToolUse never fires), a one-way/destructive typed-confirmation
rule, and a typed-reply continuation protocol for split chains.
- Regenerated all SKILL.md + ship golden fixtures; bumped affected carve
skeleton caps to absorb the always-loaded additions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat: deploy the Conductor AskUserQuestion hook (setup + upgrade migration)
The PreToolUse hook only delivers its Conductor-prose guarantee if it's
installed, but setup skips hook registration in non-interactive (conductor/CI)
setups. Two fixes so layer 3 actually deploys:
- setup: treat a Conductor workspace as an implicit opt-in for the PreToolUse
hook on the silent fall-through (never overriding an explicit opt-out).
- migration v1.58.0.0: re-register the hook for existing Conductor installs on
/gstack-upgrade, idempotent and respecting plan_tune_hooks=no.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test: E2E for Conductor prose + fix auto-decide-preserved GSTACK_HOME bug
- New skill-e2e-conductor-prose (periodic): Conductor env + plan-eng-review
surfaces a prose decision brief, not a silent skip. Header documents this is
end-to-end behavior coverage; the deterministic Conductor guard is the
question-preference-hook unit test (the PTY harness can't register the MCP
variant — Codex #10).
- Fix the pre-existing bug in auto-decide-preserved: it seeded the never-ask
preference under GSTACK_HOME=tmpHome but never passed GSTACK_HOME into the
PTY run, so the spawned claude read the real ~/.gstack and the preference
was inert (Codex #9). Now passes GSTACK_HOME + CONDUCTOR_WORKSPACE_PATH to
prove auto-decide still wins over the Conductor prose redirect.
- Register both in touchfiles (periodic tier).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* v1.58.0.0 feat: Conductor renders AskUserQuestion decisions as prose
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test: strip ambient Conductor env in memory-cache-injection hook harness
Same dev-in-Conductor leak fixed for question-preference-hook: this suite's
runHook copies process.env, so running it inside Conductor flipped the
defer-path memoryContext assertions into the [conductor] prose deny. Strip
CONDUCTOR_* so the cases characterize non-Conductor behavior. (CI is headless,
so this only bit local Conductor runs.)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat: gstack-detach — run agent eval/bench jobs in their own session
Long agent-run jobs (30-60 min evals, benchmarks) die when the harness sends
SIGTERM to a background task's process group on turn boundaries / monitor
stops / interruptions (observed: 'script test:gate terminated by signal
SIGTERM'). gstack-detach runs the command in a fresh session (python3
os.setsid, or setsid on Linux, nohup fallback) so a group SIGTERM can't reach
it, and wraps it in caffeinate -i on macOS so idle-sleep can't kill it either.
Returns immediately; caller polls the logfile. Secrets stay in env, never argv.
The guard test pins the contract: the command runs in a different process
group than the caller and outlives the launching shell.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat: eval:bg* scripts — detached eval runs for agents
Agent-facing convenience scripts that launch the eval suites through
gstack-detach so a harness SIGTERM can't kill a long run. eval:bg (diff-based),
eval:bg:all, eval:bg:gate, eval:bg:periodic — each returns immediately and
streams to /tmp/gstack-evals.log for polling. The plain test:evals / test:e2e
scripts stay foreground for humans.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: CLAUDE.md — agents must run long evals via gstack-detach
Codifies the detached-execution default: agent-launched eval/benchmark runs go
through bin/gstack-detach (or the eval:bg* scripts) so a harness SIGTERM or
macOS idle-sleep can't kill a 30-60 min run, then poll the log with a
death-aware watcher. Humans keep foreground scripts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat: harden gstack-detach against all four eval-infra killers
The basic bash detach fixed SIGTERM but a real run on a shared dev box hit
three more killers: cross-worktree API saturation (15-way concurrency x a
sibling worktree mass-timed-out the suite), a silent hang (periodic bun died
with no exit marker), and shared-/tmp log contamination (a concurrent
worktree's agent output bled into the log). Rewrite as a portable python3 tool
that bakes in all four fixes:
- fork + setsid: SIGTERM-proof (own session, survives harness polite-quit)
- caffeinate -i on macOS: no idle-sleep death
- --lock NAME (fcntl, machine-wide): concurrent worktrees SERIALIZE instead of
saturating the shared model API
- run-scoped default log (~/.gstack-dev/eval-runs/<label>-<slug>-<branch>-<ts>-<pid>):
no cross-worktree collision/contamination
- --timeout watchdog + a guaranteed '### gstack-detach EXIT=<code> ###' sentinel
on every terminal path: no silent hang, finished-vs-died always detectable
Guard test pins all four: detached pgid differs + outlives launcher, run-scoped
log path, watchdog EXIT=timeout, and lock serialization (second run WAITS).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat: eval:bg* use run-scoped logs + machine lock + watchdog
Drop the shared /tmp/gstack-evals.log path (the cross-worktree collision that
contaminated a live run) for gstack-detach's run-scoped default, and add the
machine-wide gstack-evals lock (concurrent worktrees serialize, no API
saturation) plus per-tier watchdog timeouts (60/90/120 min). Each eval:bg*
prints its run-scoped log path to poll.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: wire detached-eval guidance into /ship + correct CLAUDE.md flags
- /ship eval step (sections/tests.md): long eval suites launch via gstack-detach
(own session, machine lock, EXIT sentinel) so a turn boundary can't kill a
30+ min run mid-ship — the exact failure observed during this branch's ship.
- CLAUDE.md: correct the now-stale /tmp reference; document the --lock (serialize
worktrees, no API saturation), --timeout watchdog, run-scoped log, and the
guaranteed EXIT sentinel the poller breaks on.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor: extract pure promotedEnv() from conductor-env-shim
Single source of truth for GSTACK_* key promotion semantics. The ambient
promoteConductorEnv() becomes a wrapper; behavior-preserving. Needed by the
hermetic env builder which must not mutate process.env.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat: hermetic child-env builder for E2E runners
Allowlist scrub (basics/network/named-auth kept; CONDUCTOR_*, CLAUDE_*,
GSTACK_*, MCP_*, GBRAIN_*, operator credentials dropped), per-runner
extraAllow, overrides merge last, EVALS_HERMETIC=0 byte-identical escape
hatch read at call time (ESM-hoist safe). Sync memoized singleton temp dirs
(<runRoot>/.claude keeps the extractPlanFilePath contract), seeded
.claude.json for non-interactive first run, pid-aware GC of crashed runs.
19 free unit tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat: session-runner spawns hermetic children + isolation canaries
claude -p children now get the allowlist-scrubbed env and a gated
--strict-mcp-config (EVALS_HERMETIC=0 restores operator env AND args).
Two gate-tier canaries make the clean room falsifiable: hermetic-canary
asserts env redirect + scrub + zero MCP servers + nonzero API-key cost
from the Bash tool_result (never model prose); hermetic-sentinel plants a
poisoned operator config (user CLAUDE.md + MCP server) and proves the
child cannot see it. Empirically verified on claude 2.1.175: print mode
needs no seed config (the seed serves the PTY path); the child CLI sets
CLAUDECODE for its own tools, so that scrub is pinned in unit tests, not
E2E. hermetic-env.ts joins GLOBAL_TOUCHFILES.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat: PTY runner spawns hermetic claude sessions
launchClaudePty children get the allowlist-scrubbed env, a gated
--strict-mcp-config, and the session exposes hermeticConfigDir for
forensics (hermetic plan files live under <dir>/plans/ and still match
extractPlanFilePath via the /.claude dir-name contract). Seeded trust
state covers repo-cwd sessions; the 15s trust-watcher stays as fallback.
Verified foreground via the plan-mode-no-op gate test.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat: codex/gemini runners spawn hermetic children
Same allowlist scrub as the claude runners, with each provider's auth
surface re-admitted via extraAllow (codex: OPENAI_API_KEY/CODEX_* plus
its tempHome .codex copy; gemini: GEMINI_*/GOOGLE_* with real HOME for
~/.gemini auth). The gemini spawn previously inherited the full operator
env with no env property at all.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat: agent-sdk-runner spawns hermetic children via complete Options.env
The historical 'env: breaks SDK auth' failure was partial-env replacement:
Options.env replaces the child's entire environment, so objects lacking
ANTHROPIC_API_KEY killed auth. Passing the complete hermetic env (key +
PATH + redirected CLAUDE_CONFIG_DIR/GSTACK_HOME) works — validated live
via query() with a Bash tool call (success, real cost, Conductor vars
scrubbed). Per-test opts.env merges last; ambient key mutation still
works because the builder reads process.env at call time.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test: static tripwire pins hermetic wiring in all five runners
Free-tier invariants: every runner builds child env via hermeticChildEnv,
no raw ...process.env spread at any spawn site, --strict-mcp-config gated
on isHermeticEnabled in both claude runners, and no test callsite passes
the operator env into a runner's override parameter (scoped to runner
calls — unit tests spawning gstack bin scripts directly are exempt).
Mirrors the terminal-agent-pid-identity / server-embedder-terminal-port
tripwire idiom.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test: refresh codex/factory ship goldens with detached-eval block
a38089aa added the gstack-detach guidance to the ship template and
updated the claude golden; the codex and factory goldens missed the same
16-line block. Regenerated via bun run gen:skill-docs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: hermetic local E2E is the default; retire stale SDK env warning
CLAUDE.md now documents the hermetic clean room (allowlist scrub, fresh
seeded CLAUDE_CONFIG_DIR, temp GSTACK_HOME, --strict-mcp-config),
EVALS_HERMETIC=0 as the debug escape hatch, and replaces the 'never pass
env: to runAgentSdkTest' rule with the verified mechanism (partial-env
replacement was the failure; complete env is safe).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: operational-learning fixture copies lib/jsonl-store.ts with the bin
gstack-learnings-log imports $SCRIPT_DIR/../lib/jsonl-store.ts (hasInjection,
v1.57.5.0) — copying only the bin scripts into the temp fixture broke the
script with exit 1 since then. Latent because diff-based selection rarely
runs this test; surfaced when hermetic-env.ts joined GLOBAL_TOUCHFILES and
selected everything. Reproduced outside the hermetic env to confirm blame.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: ios-qa daemon scenarios use unique pidfiles under --concurrent
All scenarios shared join(workDir, 'daemon.pid') through a module-scope
workDir binding that beforeEach reassigns mid-flight under bun --concurrent.
First daemon claims; siblings get already_running against the test process's
own always-alive pid and fail in milliseconds — the failure mode seen at
15-way gate concurrency. Per-claim unique pidfiles keep the single-instance
semantics under test.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: workflow judge re-appends body-carved sections after the marker slice
runWorkflowJudge appended sections/*.md before slicing startMarker..endMarker.
That handles skills that moved their MARKERS into sections (plan-eng,
plan-design) but not document-release, which keeps its markers in the
skeleton and carved the workflow BODY (Steps 2-9 -> sections/release-body.md)
AFTER the endMarker — so the slice dropped it and the judge scored
completeness 2 ('Steps 2-9 are in an external file'). Now any carved section
the marker window excluded is re-appended, so the judge sees the full
workflow the agent executes. document-release: completeness 2->5, clarity
3->4. ship/plan-ceo/plan-eng/plan-design judges unchanged (their section
content is already inside the slice, so the head-dedup skips re-append).
Pre-existing since the v1.57.0.0 carve (#1907); surfaced now because
hermetic-env.ts is a global touchfile that selects every llm-judge test.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* harden: hermetic temp-dir GC grace window + half-seed cleanup
Codex adversarial review (ship) flagged two temp-dir lifecycle edges:
- GC deleted any dead-pid dir; PID reuse could delete a freshly-created dir
whose original pid exited and was recycled to a live process. Now requires
BOTH a dead pid AND mtime older than a 1h floor.
- A seed-write failure after mkdir left an unseeded dir named with our live
pid that this process's GC skips, leaking until exit. Now the partial dir
is torn down before the (still loud) rethrow.
Two findings left as-is by design: HOME stays allowlisted (CLAUDE_CONFIG_DIR
wins for claude; codex/gemini need ~/.codex|~/.gemini auth; FS sandbox is
TODOS.md:454 scope; the hermetic-sentinel canary proves config isolation),
and PTY extraArgs --mcp-config is a deliberate caller opt-in like env overrides.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: document hermetic-by-default E2E + eval:bg detached runs in CONTRIBUTING
The Testing & evals section now tells contributors that local E2E runners
spawn children through a sealed clean room (allowlist-scrubbed env, seeded
CLAUDE_CONFIG_DIR, temp GSTACK_HOME, --strict-mcp-config) so local signal
matches CI, with EVALS_HERMETIC=0 as the escape hatch. The eval-tools list
gains the eval:bg* detached-run scripts (gstack-detach: SIGTERM-proof,
caffeinate-wrapped, machine-locked, run-scoped logs, EXIT= sentinel).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore: sync package.json to 1.58.1.0
The merge took main's package.json (1.58.0.0); gstack-version-bump repair
fixed the working tree but the change was left uncommitted. Without this the
committed tree disagrees with VERSION and CI's version-match test fails.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: regenerate diagram SKILL.md with Conductor prose preamble
The diagram skill (new from main) was missing the Conductor-session prose
AskUserQuestion blocks that gen-skill-docs propagates to every SKILL.md.
Pure generated output; reproduced by bun run gen:skill-docs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
575 lines
22 KiB
TypeScript
575 lines
22 KiB
TypeScript
/**
|
|
* Claude Agent SDK wrapper for the overlay-efficacy harness.
|
|
*
|
|
* This sits alongside session-runner.ts (which drives `claude -p` as a
|
|
* subprocess) but runs the model via the published @anthropic-ai/claude-agent-sdk
|
|
* instead. The SDK exposes the same harness primitives Claude Code itself uses,
|
|
* so overlay-driven behavior change is measured against a closer approximation
|
|
* of real Claude Code than the `claude -p` subprocess path provides.
|
|
*
|
|
* Explicit design rules (from plan review):
|
|
* - Use SDK-exported SDKMessage types. No `| unknown` union collapse.
|
|
* - Permission surface is explicit: bypassPermissions + settingSources:[] +
|
|
* disallowedTools inverse. Without these, the SDK inherits user settings,
|
|
* project .claude/, and local hooks, and arms are no longer comparable.
|
|
* - Binary pinning via pathToClaudeCodeExecutable. Resolve with `which claude`
|
|
* at setup time; the SDK would otherwise use its bundled binary.
|
|
* - 3-shape rate-limit detection: thrown error, result-message error subtype,
|
|
* mid-stream SDKRateLimitEvent. All three recover on retry.
|
|
* - On retry, caller resets workspace via a setupWorkspace callback so
|
|
* partial Bash side-effects don't contaminate the next attempt.
|
|
* - Process-level semaphore caps concurrent queries across all callers in
|
|
* the same bun-test process. Composes with bun's own --concurrent flag.
|
|
*/
|
|
|
|
import {
|
|
query,
|
|
type SDKMessage,
|
|
type SDKAssistantMessage,
|
|
type SDKResultMessage,
|
|
type SDKSystemMessage,
|
|
type PermissionMode,
|
|
type SettingSource,
|
|
type Options,
|
|
type CanUseTool,
|
|
} from '@anthropic-ai/claude-agent-sdk';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import { resolveClaudeBinary as resolveClaudeBinaryShared } from '../../browse/src/claude-bin';
|
|
import { hermeticChildEnv } from './hermetic-env';
|
|
import type { SkillTestResult } from './session-runner';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface AgentSdkResult {
|
|
/** Full raw event stream for forensic recovery. */
|
|
events: SDKMessage[];
|
|
/** Assistant-typed subset, in order. */
|
|
assistantTurns: SDKAssistantMessage[];
|
|
/** Flat tool-call list, in order of emission. */
|
|
toolCalls: Array<{ tool: string; input: unknown; output: string }>;
|
|
/** Concatenated assistant text, newline-joined. */
|
|
output: string;
|
|
/** 'success' | 'error_during_execution' | 'error_max_turns' | ... */
|
|
exitReason: string;
|
|
turnsUsed: number;
|
|
durationMs: number;
|
|
firstResponseMs: number;
|
|
maxInterTurnMs: number;
|
|
costUsd: number;
|
|
model: string;
|
|
sdkVersion: string;
|
|
/** claude_code_version from the SDK's system/init event (authoritative). */
|
|
sdkClaudeCodeVersion: string;
|
|
/** Path to the claude binary we pinned. */
|
|
resolvedBinaryPath: string;
|
|
/** browse-error pattern scan for SkillTestResult parity. Always empty here. */
|
|
browseErrors: string[];
|
|
}
|
|
|
|
/** Signature matching `query()` from the SDK. DI hook for unit tests. */
|
|
export type QueryProvider = typeof query;
|
|
|
|
/** Subset of SDK Options['systemPrompt'] we support. */
|
|
export type SystemPromptOption =
|
|
| string
|
|
| { type: 'preset'; preset: 'claude_code'; append?: string; excludeDynamicSections?: boolean };
|
|
|
|
export interface RunAgentSdkOptions {
|
|
/**
|
|
* System prompt surface.
|
|
* - bare string "" -> omit entirely (SDK default: no system prompt)
|
|
* - bare string "...text..." -> REPLACE default with given text (use sparingly)
|
|
* - { type:'preset', preset:'claude_code' } -> use Claude Code default
|
|
* - { type:'preset', preset:'claude_code', append: "..." } -> default + append
|
|
*
|
|
* For overlay-efficacy measurement, the preset+append pattern is the right
|
|
* one: it measures "does adding overlay text to the REAL Claude Code system
|
|
* prompt change behavior" rather than "does the overlay alone (stripped of
|
|
* base scaffolding) change behavior".
|
|
*/
|
|
systemPrompt: SystemPromptOption;
|
|
userPrompt: string;
|
|
workingDirectory: string;
|
|
model?: string;
|
|
maxTurns?: number;
|
|
allowedTools?: string[];
|
|
disallowedTools?: string[];
|
|
permissionMode?: PermissionMode;
|
|
settingSources?: SettingSource[];
|
|
env?: Record<string, string>;
|
|
pathToClaudeCodeExecutable?: string;
|
|
testName?: string;
|
|
runId?: string;
|
|
fixtureId?: string;
|
|
queryProvider?: QueryProvider;
|
|
/** Max 429 retries per call. Default 3. */
|
|
maxRetries?: number;
|
|
/**
|
|
* Caller provides this when retry should reset the workspace. The harness
|
|
* invokes it with a fresh dir after a rate-limit failure. When omitted,
|
|
* retries reuse the original workingDirectory (fine for read-only tests).
|
|
*/
|
|
onRetry?: (freshDir: string) => void;
|
|
/**
|
|
* Optional canUseTool callback. When supplied, the harness flips
|
|
* permissionMode from 'bypassPermissions' to 'default' so the SDK actually
|
|
* routes tool-use approval decisions through the callback. Without this
|
|
* flip, bypassPermissions short-circuits the callback and tests that want
|
|
* to assert on AskUserQuestion content silently pass without asserting.
|
|
*
|
|
* Callback contract matches the SDK: fires on every tool-use approval
|
|
* request and on AskUserQuestion invocations. For non-AskUserQuestion
|
|
* tools that tests don't care about, use `passThroughNonAskUserQuestion`
|
|
* to auto-allow them.
|
|
*/
|
|
canUseTool?: CanUseTool;
|
|
}
|
|
|
|
/**
|
|
* Pass-through helper: auto-allows any tool_use that isn't AskUserQuestion.
|
|
* Most plan-mode handshake tests only care about the handshake AskUserQuestion;
|
|
* every other tool (Read, Grep, Bash, Write, Edit, ExitPlanMode) should just
|
|
* run. Compose with a test-specific AskUserQuestion handler:
|
|
*
|
|
* canUseTool: async (toolName, input, options) => {
|
|
* if (toolName === 'AskUserQuestion') {
|
|
* // custom assertions + canned answer
|
|
* return { behavior: 'allow', updatedInput: { questions: input.questions, answers: {...} } };
|
|
* }
|
|
* return passThroughNonAskUserQuestion(toolName, input);
|
|
* }
|
|
*/
|
|
export function passThroughNonAskUserQuestion(
|
|
toolName: string,
|
|
input: Record<string, unknown>,
|
|
): { behavior: 'allow'; updatedInput: Record<string, unknown> } {
|
|
// SDK requires an allow response to include updatedInput — pass the original
|
|
// input through unchanged so the tool runs as the model intended.
|
|
void toolName;
|
|
return { behavior: 'allow', updatedInput: input };
|
|
}
|
|
|
|
export class RateLimitExhaustedError extends Error {
|
|
readonly attempts: number;
|
|
constructor(attempts: number, cause?: unknown) {
|
|
super(`rate limit exhausted after ${attempts} attempts`);
|
|
this.name = 'RateLimitExhaustedError';
|
|
this.attempts = attempts;
|
|
if (cause !== undefined) (this as { cause?: unknown }).cause = cause;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Process-level semaphore for API concurrency
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Bounded token bucket. Shared across all runAgentSdkTest calls in this
|
|
* process so that bun's --concurrent flag does not compound with in-test
|
|
* concurrency to blow past Anthropic's rate limits.
|
|
*
|
|
* Default capacity 3. Override via GSTACK_SDK_MAX_CONCURRENCY env var.
|
|
*/
|
|
class Semaphore {
|
|
private available: number;
|
|
private readonly queue: Array<() => void> = [];
|
|
constructor(capacity: number) {
|
|
this.available = capacity;
|
|
}
|
|
async acquire(): Promise<void> {
|
|
if (this.available > 0) {
|
|
this.available--;
|
|
return;
|
|
}
|
|
await new Promise<void>((resolve) => this.queue.push(resolve));
|
|
}
|
|
release(): void {
|
|
const next = this.queue.shift();
|
|
if (next) {
|
|
next();
|
|
} else {
|
|
this.available++;
|
|
}
|
|
}
|
|
/** For tests. Returns tokens currently in-flight. */
|
|
inFlight(): number {
|
|
// Not introspectable from outside without tracking; approximate.
|
|
return this.queue.length;
|
|
}
|
|
}
|
|
|
|
const DEFAULT_SDK_CONCURRENCY = Number(process.env.GSTACK_SDK_MAX_CONCURRENCY ?? 3);
|
|
let _apiSemaphore: Semaphore | null = null;
|
|
function getApiSemaphore(): Semaphore {
|
|
if (!_apiSemaphore) _apiSemaphore = new Semaphore(DEFAULT_SDK_CONCURRENCY);
|
|
return _apiSemaphore;
|
|
}
|
|
|
|
/** Test-only. Resets the process-level semaphore. */
|
|
export function __resetSemaphoreForTests(capacity: number): void {
|
|
_apiSemaphore = new Semaphore(capacity);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Rate-limit detection
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** True if `err` looks like a rate-limit thrown from the SDK. */
|
|
export function isRateLimitThrown(err: unknown): boolean {
|
|
if (!err || typeof err !== 'object') return false;
|
|
const msg = (err as { message?: string }).message ?? '';
|
|
const name = (err as { name?: string }).name ?? '';
|
|
const status = (err as { status?: number }).status;
|
|
return (
|
|
status === 429 ||
|
|
/rate.?limit|429|too many requests/i.test(msg) ||
|
|
/RateLimit/i.test(name)
|
|
);
|
|
}
|
|
|
|
/** True if a SDKResultMessage is a rate-limit-shaped error. */
|
|
export function isRateLimitResult(msg: SDKMessage): boolean {
|
|
if (msg.type !== 'result') return false;
|
|
const r = msg as SDKResultMessage;
|
|
if (r.subtype === 'success') return false;
|
|
// subtype === 'error_during_execution' | 'error_max_turns' | 'error_max_budget_usd' | ...
|
|
if (r.subtype !== 'error_during_execution') return false;
|
|
const errs = (r as { errors?: string[] }).errors ?? [];
|
|
return errs.some((e) => /rate.?limit|429|too many requests/i.test(e));
|
|
}
|
|
|
|
/** True if mid-stream SDKRateLimitEvent indicates a blocking rate-limit. */
|
|
export function isRateLimitEvent(msg: SDKMessage): boolean {
|
|
if (msg.type !== 'rate_limit_event') return false;
|
|
const info = (msg as { rate_limit_info?: { status?: string } }).rate_limit_info;
|
|
return info?.status === 'rejected';
|
|
}
|
|
|
|
/**
|
|
* True if `err` is the SDK's "max turns reached" throw. Some SDK versions
|
|
* raise this as an exception from the generator instead of emitting a
|
|
* result message with subtype='error_max_turns'. We treat it as terminal-
|
|
* but-recoverable: record what we collected and continue, rather than
|
|
* failing the whole run.
|
|
*/
|
|
export function isMaxTurnsError(err: unknown): boolean {
|
|
if (!err || typeof err !== 'object') return false;
|
|
const msg = (err as { message?: string }).message ?? '';
|
|
return /reached maximum number of turns|max.?turns/i.test(msg);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Version resolution (cached)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
let _sdkVersionCache: string | null = null;
|
|
function resolveSdkVersion(): string {
|
|
if (_sdkVersionCache) return _sdkVersionCache;
|
|
try {
|
|
const pkgPath = require.resolve('@anthropic-ai/claude-agent-sdk/package.json');
|
|
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')) as { version?: string };
|
|
_sdkVersionCache = pkg.version ?? 'unknown';
|
|
} catch {
|
|
_sdkVersionCache = 'unknown';
|
|
}
|
|
return _sdkVersionCache;
|
|
}
|
|
|
|
export function resolveClaudeBinary(): string | null {
|
|
return resolveClaudeBinaryShared();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main runner
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Execute a single SDK query with retries. Returns a typed result.
|
|
*
|
|
* The retry loop treats 429 as recoverable and any other error as fatal.
|
|
* Exponential backoff: 1s, 2s, 4s. After maxRetries failures, throws
|
|
* RateLimitExhaustedError so the caller can decide what to do with the run.
|
|
*/
|
|
export async function runAgentSdkTest(
|
|
opts: RunAgentSdkOptions,
|
|
): Promise<AgentSdkResult> {
|
|
const sem = getApiSemaphore();
|
|
const maxRetries = opts.maxRetries ?? 3;
|
|
const queryImpl: QueryProvider = opts.queryProvider ?? query;
|
|
const model = opts.model ?? 'claude-opus-4-7';
|
|
|
|
// NOTE on env: the SDK child gets the COMPLETE hermetic env (allowlist
|
|
// scrub + ANTHROPIC_API_KEY + hermetic CLAUDE_CONFIG_DIR/GSTACK_HOME), with
|
|
// per-test opts.env merging last. The historical "passing env: breaks SDK
|
|
// auth" failure (old CLAUDE.md warning) was partial-env replacement —
|
|
// Options.env REPLACES the child's entire environment, so an object without
|
|
// the key killed auth. A complete env is safe (validated 2026-06-12 via
|
|
// query() with hermeticChildEnv(): success, real cost, Bash tool working).
|
|
// Do not mutate process.env ambiently here (it would leak into later
|
|
// interactive-path tests in the same Bun process — Codex review finding);
|
|
// ambient ANTHROPIC_API_KEY mutation by tests still works because the
|
|
// builder reads process.env at call time.
|
|
|
|
let attempt = 0;
|
|
let lastErr: unknown = null;
|
|
|
|
while (attempt <= maxRetries) {
|
|
await sem.acquire();
|
|
const startMs = Date.now();
|
|
|
|
// Hoisted so the max-turns catch branch can synthesize a result from
|
|
// whatever we captured before the SDK threw.
|
|
const events: SDKMessage[] = [];
|
|
const assistantTurns: SDKAssistantMessage[] = [];
|
|
const toolCalls: Array<{ tool: string; input: unknown; output: string }> = [];
|
|
const assistantTextParts: string[] = [];
|
|
let firstResponseMs = 0;
|
|
let lastEventMs = startMs;
|
|
let maxInterTurnMs = 0;
|
|
let systemInitVersion = 'unknown';
|
|
let rateLimited: unknown = null;
|
|
let terminalResult: SDKResultMessage | null = null;
|
|
|
|
try {
|
|
// When canUseTool is supplied, the SDK must route tool-use approval
|
|
// decisions through the callback. bypassPermissions short-circuits
|
|
// that. Flip to 'default' mode so canUseTool actually fires. Tests
|
|
// that want AskUserQuestion interception without this flip would
|
|
// silently auto-pass — the exact testability gap D14/D4-eng fix.
|
|
const hasCanUseTool = typeof opts.canUseTool === 'function';
|
|
const resolvedPermissionMode: PermissionMode =
|
|
opts.permissionMode ?? (hasCanUseTool ? 'default' : 'bypassPermissions');
|
|
|
|
// When canUseTool is supplied, ensure AskUserQuestion is in the allowed
|
|
// tools list. Without it, Claude can't invoke AskUserQuestion at all
|
|
// and the callback never has a chance to fire on it.
|
|
const baseTools = opts.allowedTools ?? ['Read', 'Glob', 'Grep', 'Bash'];
|
|
const resolvedTools =
|
|
hasCanUseTool && !baseTools.includes('AskUserQuestion')
|
|
? [...baseTools, 'AskUserQuestion']
|
|
: baseTools;
|
|
|
|
const sdkOpts: Options = {
|
|
model,
|
|
cwd: opts.workingDirectory,
|
|
maxTurns: opts.maxTurns ?? 5,
|
|
tools: resolvedTools,
|
|
disallowedTools: opts.disallowedTools,
|
|
allowedTools: resolvedTools,
|
|
permissionMode: resolvedPermissionMode,
|
|
allowDangerouslySkipPermissions: resolvedPermissionMode === 'bypassPermissions',
|
|
settingSources: opts.settingSources ?? [],
|
|
env: hermeticChildEnv(opts.env),
|
|
pathToClaudeCodeExecutable: opts.pathToClaudeCodeExecutable,
|
|
...(hasCanUseTool ? { canUseTool: opts.canUseTool } : {}),
|
|
};
|
|
// Empty bare string means "omit entirely" (SDK runs with no override).
|
|
// Any object or non-empty string is passed through.
|
|
if (typeof opts.systemPrompt === 'object' || opts.systemPrompt !== '') {
|
|
sdkOpts.systemPrompt = opts.systemPrompt;
|
|
}
|
|
|
|
const q = queryImpl({
|
|
prompt: opts.userPrompt,
|
|
options: sdkOpts,
|
|
});
|
|
|
|
for await (const ev of q) {
|
|
const now = Date.now();
|
|
if (firstResponseMs === 0) firstResponseMs = now - startMs;
|
|
const interTurn = now - lastEventMs;
|
|
if (interTurn > maxInterTurnMs) maxInterTurnMs = interTurn;
|
|
lastEventMs = now;
|
|
|
|
events.push(ev);
|
|
|
|
if (ev.type === 'system' && (ev as SDKSystemMessage).subtype === 'init') {
|
|
systemInitVersion =
|
|
(ev as SDKSystemMessage).claude_code_version ?? 'unknown';
|
|
} else if (ev.type === 'assistant') {
|
|
const am = ev as SDKAssistantMessage;
|
|
assistantTurns.push(am);
|
|
const content = am.message?.content;
|
|
if (Array.isArray(content)) {
|
|
for (const block of content as Array<
|
|
| { type: 'text'; text?: string }
|
|
| { type: 'tool_use'; name?: string; input?: unknown }
|
|
| { type: string }
|
|
>) {
|
|
if (block.type === 'text') {
|
|
const t = (block as { text?: string }).text;
|
|
if (t) assistantTextParts.push(t);
|
|
} else if (block.type === 'tool_use') {
|
|
const tb = block as { name?: string; input?: unknown };
|
|
toolCalls.push({
|
|
tool: tb.name ?? 'unknown',
|
|
input: tb.input ?? {},
|
|
output: '',
|
|
});
|
|
}
|
|
}
|
|
}
|
|
} else if (isRateLimitEvent(ev)) {
|
|
rateLimited = new Error(
|
|
`mid-stream rate limit: ${JSON.stringify(
|
|
(ev as { rate_limit_info?: unknown }).rate_limit_info,
|
|
)}`,
|
|
);
|
|
} else if (ev.type === 'result') {
|
|
terminalResult = ev as SDKResultMessage;
|
|
if (isRateLimitResult(ev)) {
|
|
rateLimited = new Error(
|
|
`result-message rate limit: ${((ev as { errors?: string[] }).errors ?? []).join('; ')}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (rateLimited) {
|
|
throw rateLimited;
|
|
}
|
|
if (!terminalResult) {
|
|
throw new Error('query stream ended without a result event');
|
|
}
|
|
|
|
const durationMs = Date.now() - startMs;
|
|
const costUsd =
|
|
(terminalResult as { total_cost_usd?: number }).total_cost_usd ?? 0;
|
|
const turnsUsed =
|
|
(terminalResult as { num_turns?: number }).num_turns ??
|
|
assistantTurns.length;
|
|
const exitReason =
|
|
(terminalResult as { subtype?: string }).subtype ?? 'unknown';
|
|
|
|
return {
|
|
events,
|
|
assistantTurns,
|
|
toolCalls,
|
|
output: assistantTextParts.join('\n'),
|
|
exitReason,
|
|
turnsUsed,
|
|
durationMs,
|
|
firstResponseMs,
|
|
maxInterTurnMs,
|
|
costUsd,
|
|
model,
|
|
sdkVersion: resolveSdkVersion(),
|
|
sdkClaudeCodeVersion: systemInitVersion,
|
|
resolvedBinaryPath: opts.pathToClaudeCodeExecutable ?? 'sdk-default',
|
|
browseErrors: [],
|
|
};
|
|
} catch (err) {
|
|
lastErr = err;
|
|
|
|
// "Max turns reached" is the SDK's way of saying "this session ran
|
|
// out of turns." It's thrown from the generator instead of emitted
|
|
// as a result message. Treat as a successful-but-capped trial: the
|
|
// assistant turns we collected are real and carry a metric. Record
|
|
// them with exitReason='error_max_turns' rather than failing the
|
|
// whole run.
|
|
if (isMaxTurnsError(err)) {
|
|
const durationMs = Date.now() - startMs;
|
|
return {
|
|
events,
|
|
assistantTurns,
|
|
toolCalls,
|
|
output: assistantTextParts.join('\n'),
|
|
exitReason: 'error_max_turns',
|
|
turnsUsed: assistantTurns.length,
|
|
durationMs,
|
|
firstResponseMs,
|
|
maxInterTurnMs,
|
|
costUsd: 0, // unknown from thrown-error path
|
|
model,
|
|
sdkVersion: resolveSdkVersion(),
|
|
sdkClaudeCodeVersion: systemInitVersion,
|
|
resolvedBinaryPath: opts.pathToClaudeCodeExecutable ?? 'sdk-default',
|
|
browseErrors: [],
|
|
};
|
|
}
|
|
|
|
const isRetryable = isRateLimitThrown(err);
|
|
if (!isRetryable || attempt >= maxRetries) {
|
|
if (isRetryable) {
|
|
throw new RateLimitExhaustedError(attempt + 1, err);
|
|
}
|
|
throw err;
|
|
}
|
|
attempt++;
|
|
// backoff: 1s, 2s, 4s
|
|
await new Promise((r) => setTimeout(r, 1000 * Math.pow(2, attempt - 1)));
|
|
// Let caller reset workspace since prior attempt may have partially
|
|
// mutated files via Bash.
|
|
if (opts.onRetry) {
|
|
opts.onRetry(opts.workingDirectory);
|
|
}
|
|
} finally {
|
|
sem.release();
|
|
}
|
|
}
|
|
|
|
throw new RateLimitExhaustedError(attempt + 1, lastErr);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Legacy shape mapper
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Adapt AgentSdkResult to the legacy SkillTestResult shape so helpers that
|
|
* expect the old `claude -p` output (extractToolSummary, etc) work unchanged.
|
|
*/
|
|
export function toSkillTestResult(r: AgentSdkResult): SkillTestResult {
|
|
// Cost estimate: use SDK's authoritative cost; back-compute chars.
|
|
// session-runner.ts:30 requires inputChars/outputChars/estimatedTokens.
|
|
// These are rough; real consumers of CostEstimate use cost + turns.
|
|
const outputChars = r.output.length;
|
|
const inputChars = 0; // unknown from SDK path; not used for pass/fail
|
|
const estimatedTokens = Math.round((inputChars + outputChars) / 4);
|
|
|
|
// Build a flat transcript list mimicking the NDJSON shape:
|
|
// parseNDJSON emits [{ type: 'assistant', message: {...} }, ...].
|
|
// Use the SDK's assistantTurns directly since their shape matches.
|
|
const transcript: unknown[] = r.events.slice();
|
|
|
|
return {
|
|
toolCalls: r.toolCalls,
|
|
browseErrors: r.browseErrors,
|
|
exitReason: r.exitReason,
|
|
duration: r.durationMs,
|
|
output: r.output,
|
|
costEstimate: {
|
|
inputChars,
|
|
outputChars,
|
|
estimatedTokens,
|
|
estimatedCost: r.costUsd,
|
|
turnsUsed: r.turnsUsed,
|
|
},
|
|
transcript,
|
|
model: r.model,
|
|
firstResponseMs: r.firstResponseMs,
|
|
maxInterTurnMs: r.maxInterTurnMs,
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Metric helpers (re-exported for fixtures)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Count `tool_use` blocks in the first assistant turn of an SDK result.
|
|
* Returns 0 if there is no first turn or no content array.
|
|
*
|
|
* This is the core "fanout" metric. A turn with N tool_use blocks = N
|
|
* parallel tool invocations.
|
|
*/
|
|
export function firstTurnParallelism(firstTurn: SDKAssistantMessage | undefined): number {
|
|
if (!firstTurn) return 0;
|
|
const content = firstTurn.message?.content;
|
|
if (!Array.isArray(content)) return 0;
|
|
return (content as Array<{ type: string }>).filter((b) => b.type === 'tool_use').length;
|
|
}
|