diff --git a/deepgym/CHANGELOG.md b/deepgym/CHANGELOG.md index 2cc6c230..8d2f830f 100644 --- a/deepgym/CHANGELOG.md +++ b/deepgym/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## Unreleased + +- **Unsandboxed runs leave a trace:** every local `RunResult` records how its solution was isolated (`isolation`: `macos`, `bwrap`, or `none` under `DEEPGYM_ALLOW_UNSANDBOXED`), and the process logs a warning once when local solutions run unsandboxed. A local session's results record `none`, since its agent actions run on the host, and creating one warns once. +- **Closed sessions stay closed:** once `close()` runs (even if the delete fails), `start`, `step`, `run_cli`, `finalize_cli` and `score` raise the new `SessionClosedError` before touching the sandbox; `score()` still returns a result a failed step already fixed. The API answers a request that raced a DELETE with 409, so a host-deleted sandbox is never scored as the agent's 0. A session whose setup fails is closed through the same pipeline as a DELETE, so a failed delete keeps its slot, is retried by the sweep and is reported in `leaked_sandboxes` if it never succeeds. +- **Harbor:** remote datasets are cached in a folder keyed by a digest of the full URL and reused only while git confirms the folder is a clone of that URL; two URLs that read alike no longer share tasks. Test output is kept whole in results and reward errors. +- **Console:** episodes with no verdict show as unscored (`has_verdict` and `error_type` are stored, and existing rows are migrated from their saved results) instead of as a failed 0. Episodes and runs are paged on the server with a total, with no 200-row default and no upper bound, and a page that a refresh empties moves back to the last page. +- **No generation caps:** the Z.ai client sends no `max_tokens` unless asked, has no read timeout by default and returns a `Completion` with its `finish_reason`; `Completion.whole` is True only for a natural stop, False when a limit or filter cut the reply, None when unknown, and the QA difficulty probe, the CyberGym scripts and the seed generator score only whole answers, so a probe is inconclusive, never `too_hard`, when a sample isn't one. Breaking: `make_lm_eval_task` takes a required `max_gen_toks`, the model's maximum output (lm-eval needs one and would otherwise default to 256). Claude calls in the adversarial tester and RL exploit discovery stream at the model's maximum output, read a smaller model's maximum from its error, and refuse a reply cut off at that limit or a stream that ended before `message_stop`, so a partial attack list can't certify a verifier as robust. The CyberGym eval scripts cache each reply with its finish reason (`cached_completion`, in a record beside any legacy text cache, which stays opaque text of unknown completion), drop their default response deadlines, average only judged runs of whole answers (cut-off, unknown-completion, fallback and no-verdict runs are counted apart), and no longer build a fallback answer from the expected answer. +- **No trimmed output:** errors, adversarial and exploit reports, and QA and `deepgym check` details carry command output, code and details whole. Audit ids default to the environment's identity (`environment_id`: its name, else a digest of its whole task) instead of the task's first 80 characters, which two tasks could share, and the stored version hash (`grading_fingerprint`) covers the whole environment definition, Harbor's test scripts included, so any changed grading input invalidates the old result. + ## 0.6.1 (2026-10-23) - **Local sandbox reads only what a run needs:** under `sandbox-exec` and `bwrap`, a solution can read the operating system, the software trees a Python links against, the Python installation and its own run's files, and nothing else. Before, only the home directory, this process's runs and daemon sockets were hidden, so a solution could read files under `/tmp` and `$TMPDIR`, including another DeepGym process's verifier copies. Every deepgym package on the import path stays unreadable, not just the one running. Runs roots that a killed process left behind are removed by the next process. diff --git a/deepgym/README.md b/deepgym/README.md index 28f0cff2..db4d66c6 100644 --- a/deepgym/README.md +++ b/deepgym/README.md @@ -228,7 +228,11 @@ import lm_eval from deepgym import load_environment from deepgym.integrations.lm_eval import make_lm_eval_task -tasks = [make_lm_eval_task(load_environment(n), n) for n in ['coin_change', 'two_sum']] +# max_gen_toks: your model's maximum output (Qwen2.5 generates up to 8K tokens). +tasks = [ + make_lm_eval_task(load_environment(n), n, max_gen_toks=8192) + for n in ['coin_change', 'two_sum'] +] results = lm_eval.simple_evaluate( model='hf', model_args='pretrained=Qwen/Qwen2.5-0.5B-Instruct', tasks=tasks ) diff --git a/deepgym/console-ui/components/episodes/episode-replay.tsx b/deepgym/console-ui/components/episodes/episode-replay.tsx index 5a66d0ce..75f30b5d 100644 --- a/deepgym/console-ui/components/episodes/episode-replay.tsx +++ b/deepgym/console-ui/components/episodes/episode-replay.tsx @@ -10,7 +10,7 @@ import { Kbd } from '@/components/base/kbd/kbd'; import { CodeBlock } from '@/components/primitives/code-block'; import { ThinkingTrace } from '@/components/primitives/thinking-trace'; import { Card } from '@/components/ui/card'; -import { PassBadge } from '@/components/ui/status-badge'; +import { VerdictBadge } from '@/components/ui/status-badge'; import { formatScore } from '@/lib/format'; import type { EpisodeDetail } from '@/lib/types'; import { useHotkey } from '@/lib/use-hotkey'; @@ -140,9 +140,9 @@ export function EpisodeReplay({ episode }: { episode: EpisodeDetail }) {
- {formatScore(episode.result.score)} + {formatScore(episode.score)} - +
{episode.result.output && (

{episode.result.output}

diff --git a/deepgym/console-ui/components/episodes/episodes-page.test.tsx b/deepgym/console-ui/components/episodes/episodes-page.test.tsx new file mode 100644 index 00000000..5d567de6 --- /dev/null +++ b/deepgym/console-ui/components/episodes/episodes-page.test.tsx @@ -0,0 +1,139 @@ +import { afterEach, beforeEach, expect, jest, test } from 'bun:test'; +import { act } from 'react'; +import type { EpisodeDetail, EpisodePage, EpisodeSummary } from '@/lib/types'; +import { cleanup, fakeFetch, render, settle } from '@/test/dom'; +import { navigation } from '@/test/navigation'; + +// After the navigation mock, so the page reads ?id= from it. +const { EpisodesPage } = await import('./episodes-page'); + +afterEach(cleanup); +beforeEach(() => navigation.reset('/episodes/')); + +const episode = (id: string, extra: Partial = {}): EpisodeSummary => ({ + id, + environment: `env-${id}`, + steps: 2, + total_reward: 0, + score: 1, + passed: true, + has_verdict: true, + error_type: null, + created_at: '2026-10-23T10:00:00Z', + ...extra, +}); + +/** The provider failed before the verifier judged it: no score, no pass. */ +const crashed = episode('crashed', { + score: null, + passed: null, + has_verdict: false, + error_type: 'provider_error', +}); +/** Judged and failed: a real 0. */ +const zero = episode('zero', { score: 0, passed: false }); + +const page = (episodes: EpisodeSummary[], total: number) => + Response.json({ episodes, total } satisfies EpisodePage); + +const rowText = (text: string) => + [...document.querySelectorAll('[role="row"]')].find((row) => row.textContent?.includes(text))?.textContent; + +test('an episode without a verdict is listed as no verdict with its error, not as a failed 0', async () => { + fakeFetch(() => page([crashed, zero], 2)); + await render(); + await settle(); + + expect(rowText('env-crashed')).toContain('no verdictprovider_error'); + expect(rowText('env-crashed')).not.toContain('fail'); + expect(rowText('env-zero')).toContain('0.00fail'); +}); + +test('replaying an episode without a verdict shows its error and result output, not a failed 0', async () => { + navigation.reset('/episodes/', 'id=crashed'); + const detail: EpisodeDetail = { + ...crashed, + frames: [ + { + index: 0, + content: '', + done: true, + metadata: null, + action: null, + reward: null, + has_screenshot: false, + }, + ], + result: { + score: 0, + passed: false, + output: 'provider unreachable', + stderr: '', + exit_code: 1, + execution_time_ms: 0, + sandbox_id: 'error', + reward_components: null, + error_type: 'provider_error', + cases: null, + }, + }; + fakeFetch((url) => (url === '/api/episodes/crashed' ? Response.json(detail) : page([crashed], 1))); + await render(); + await settle(); + + const card = [...document.querySelectorAll('section')].find((section) => + section.textContent?.startsWith('Final score'), + ); + expect(card?.textContent).toBe('Final score-no verdictprovider_errorprovider unreachable'); +}); + +test('every episode is reachable: the pager fetches the next page from the server', async () => { + const newest = Array.from({ length: 25 }, (_, index) => episode(`new${index}`)); + const requested: string[] = []; + fakeFetch((url) => { + requested.push(url); + return url.includes('offset=25') ? page([episode('oldest')], 26) : page(newest, 26); + }); + await render(); + await settle(); + expect(rowText('env-oldest')).toBeUndefined(); + + const next = document.querySelector('button[aria-label="Go to page 2"]'); + if (!next) throw new Error('no pager'); + await act(async () => next.click()); + await settle(); + + expect(requested).toEqual(['/api/episodes?offset=0&limit=25', '/api/episodes?offset=25&limit=25']); + expect(rowText('env-oldest')).toContain('env-oldest'); + expect(rowText('env-new0')).toBeUndefined(); +}); + +test('a poll that leaves the page shown past the last one moves back to the last page', async () => { + jest.useFakeTimers(); + const recorded = Array.from({ length: 26 }, (_, index) => episode(`e${index}`)); + const requested: string[] = []; + fakeFetch((url) => { + requested.push(url); + const offset = Number(new URL(url, 'http://localhost').searchParams.get('offset')); + return page(recorded.slice(offset, offset + 25), recorded.length); + }); + await render(); + await settle(); + const next = document.querySelector('button[aria-label="Go to page 2"]'); + if (!next) throw new Error('no pager'); + await act(async () => next.click()); + await settle(); + expect(rowText('env-e25')).toContain('env-e25'); + + // One episode is deleted, so the next poll of page 2 finds it empty, with one page left. + recorded.pop(); + await act(async () => jest.advanceTimersByTime(10_000)); + await settle(); + + expect(requested.slice(-2)).toEqual([ + '/api/episodes?offset=25&limit=25', + '/api/episodes?offset=0&limit=25', + ]); + expect(document.querySelectorAll('[role="row"]')).toHaveLength(26); + expect(rowText('env-e0')).toContain('env-e0'); +}); diff --git a/deepgym/console-ui/components/episodes/episodes-page.tsx b/deepgym/console-ui/components/episodes/episodes-page.tsx index 260d3338..ca563a0c 100644 --- a/deepgym/console-ui/components/episodes/episodes-page.tsx +++ b/deepgym/console-ui/components/episodes/episodes-page.tsx @@ -7,10 +7,11 @@ import { Card } from '@/components/ui/card'; import { DataTable } from '@/components/ui/data-table'; import { EmptyState } from '@/components/ui/empty-state'; import { PageHeader } from '@/components/ui/page-header'; -import { PassBadge } from '@/components/ui/status-badge'; +import { VerdictBadge } from '@/components/ui/status-badge'; import { useApi } from '@/lib/api'; import { formatScore, timeAgo } from '@/lib/format'; -import type { EpisodeDetail, EpisodeSummary } from '@/lib/types'; +import type { EpisodeDetail, EpisodePage, EpisodeSummary } from '@/lib/types'; +import { usePagedApi } from '@/lib/use-paged-api'; import { useQueryParam } from '@/lib/use-query-param'; import { EpisodeReplay } from './episode-replay'; @@ -38,16 +39,15 @@ const columns: ColumnDef[] = [ { id: 'steps', accessorKey: 'steps', header: 'Steps', meta: { align: 'end', className: 'font-mono' } }, { id: 'score', - accessorFn: (episode) => episode.score ?? -1, + accessorKey: 'score', header: 'Score', cell: ({ row }) => formatScore(row.original.score), meta: { align: 'end', className: 'font-mono' }, }, { - id: 'passed', + id: 'verdict', header: '', - enableSorting: false, - cell: ({ row }) => , + cell: ({ row }) => , }, ]; @@ -63,11 +63,10 @@ function Replay({ id }: { id: string }) { return ; } -/** Recorded episodes on the left; the selected one (?id=) replays on the right. */ +/** Recorded episodes on the left, a server page at a time; the selected one (?id=) replays on the right. */ export function EpisodesPage() { - const { data, error } = useApi('/episodes', 10_000); + const { data, error, paging } = usePagedApi('/episodes', 10_000); const [selected, setSelected] = useQueryParam('id'); - const episodes = data ?? []; return ( <> @@ -75,7 +74,7 @@ export function EpisodesPage() { title="Episodes" description="Recorded multi-turn and computer-use trajectories, replayed step by step." /> - {data && episodes.length === 0 ? ( + {data?.total === 0 ? ( Store a finished episode from your agent loop and it shows up here: @@ -87,10 +86,10 @@ export function EpisodesPage() { setSelected(episode.id)} - pageSize={25} + serverPaging={paging} empty={ {error} diff --git a/deepgym/console-ui/components/overview/overview-page.tsx b/deepgym/console-ui/components/overview/overview-page.tsx index 393d5cd8..5d5d212a 100644 --- a/deepgym/console-ui/components/overview/overview-page.tsx +++ b/deepgym/console-ui/components/overview/overview-page.tsx @@ -13,7 +13,7 @@ import { RangeControl, useRange } from '@/components/ui/range-control'; import { useApi } from '@/lib/api'; import { timeAgo } from '@/lib/format'; import { RANGES } from '@/lib/stats'; -import type { QAReport, RunStats, RunSummary } from '@/lib/types'; +import type { QAReport, RunPage, RunStats } from '@/lib/types'; import { recentActivity } from '@/lib/verdict'; import { EnvHealth } from './env-health'; @@ -22,9 +22,9 @@ export function OverviewPage() { const router = useRouter(); const [range, setRange] = useRange(); const { data: stats, error } = useApi(`/runs/stats?hours=${RANGES[range]}`, 5000); - const { data: runs } = useApi('/runs?limit=10', 5000); + const { data: latest } = useApi('/runs?limit=10', 5000); const { data: reports } = useApi('/qa', 15_000); - const activity = recentActivity(runs ?? [], reports ?? [], 10); + const activity = recentActivity(latest?.runs ?? [], reports ?? [], 10); return ( <> @@ -58,8 +58,8 @@ export function OverviewPage() { {activity.length ? ( ({ ...item, meta: timeAgo(item.at) }))} /> ) : ( - - {runs && reports ? 'Runs and QA verdicts show up here as they happen.' : null} + + {latest && reports ? 'Runs and QA verdicts show up here as they happen.' : null} )} diff --git a/deepgym/console-ui/components/runs/run-detail-page.tsx b/deepgym/console-ui/components/runs/run-detail-page.tsx index 81611b75..f975d030 100644 --- a/deepgym/console-ui/components/runs/run-detail-page.tsx +++ b/deepgym/console-ui/components/runs/run-detail-page.tsx @@ -13,7 +13,7 @@ import { PageHeader } from '@/components/ui/page-header'; import { VerdictBanner } from '@/components/ui/verdict-banner'; import { api, errorText, useApi } from '@/lib/api'; import { formatScore, timeAgo } from '@/lib/format'; -import type { RunDetail, RunSummary } from '@/lib/types'; +import type { RunDetail, RunPage, RunSummary } from '@/lib/types'; import { useQueryParam } from '@/lib/use-query-param'; import { failuresFirst, isActive, runTaskStatus, verdictLine } from '@/lib/verdict'; import { RunProgress } from './run-progress'; @@ -22,9 +22,10 @@ import { RunSummary as RunSummaryStrip } from './run-summary'; import { ScoreBreakdown } from './score-breakdown'; function GroupRuns({ run }: { run: RunDetail }) { - const { data } = useApi(`/runs?group_id=${run.group_id}`, 2500, (runs) => + const { data: page } = useApi(`/runs?group_id=${run.group_id}`, 2500, ({ runs }) => runs.some(isActive), ); + const data = page?.runs; if (!data || data.length < 2) return null; const running = data.filter(isActive).length; const failed = data.filter((item) => !isActive(item) && !item.passed).length; diff --git a/deepgym/console-ui/components/runs/run-progress.tsx b/deepgym/console-ui/components/runs/run-progress.tsx index 48108f44..fe3002c4 100644 --- a/deepgym/console-ui/components/runs/run-progress.tsx +++ b/deepgym/console-ui/components/runs/run-progress.tsx @@ -4,17 +4,17 @@ import { Card } from '@/components/ui/card'; import { useApi } from '@/lib/api'; import { formatDuration } from '@/lib/format'; import { etaNote, medianDuration } from '@/lib/stats'; -import type { RunDetail, RunSummary } from '@/lib/types'; +import type { RunDetail, RunPage } from '@/lib/types'; import { cx } from '@/utils/cx'; export const KEEPS_GOING = 'You can leave this page, the run keeps going.'; /** Median duration of the latest finished runs of `environment` (null while loading or with none). */ export function useTypicalDuration(environment: string | null): number | null { - const { data } = useApi( + const { data } = useApi( environment ? `/runs?environment=${encodeURIComponent(environment)}&status=completed&limit=20` : null, ); - return data ? medianDuration(data) : null; + return data ? medianDuration(data.runs) : null; } /** diff --git a/deepgym/console-ui/components/runs/runs-page.test.tsx b/deepgym/console-ui/components/runs/runs-page.test.tsx index d722dc9f..26ffaef8 100644 --- a/deepgym/console-ui/components/runs/runs-page.test.tsx +++ b/deepgym/console-ui/components/runs/runs-page.test.tsx @@ -1,6 +1,6 @@ -import { afterEach, beforeEach, expect, test } from 'bun:test'; +import { afterEach, beforeEach, expect, jest, test } from 'bun:test'; import { act } from 'react'; -import type { EnvironmentSummary, RunSummary } from '@/lib/types'; +import type { EnvironmentSummary, RunPage, RunSummary } from '@/lib/types'; import { cleanup, fakeFetch, render, settle } from '@/test/dom'; import { navigation } from '@/test/navigation'; @@ -38,7 +38,9 @@ const created = (id: string, environment: string): RunSummary => ({ finished_at: null, }); -/** The console API: POST /runs starts one run per environment; stats never arrive. */ +const page = (runs: RunSummary[], total: number) => Response.json({ runs, total } satisfies RunPage); + +/** The console API: POST /runs starts one run per environment; none are listed; stats never arrive. */ function serve() { const posts: string[][] = []; fakeFetch((url, init) => { @@ -50,7 +52,7 @@ function serve() { if (url.startsWith('/api/environments')) return Response.json([env('echo'), env('reverse')]); if (url === '/api/runs/config') return Response.json({ local_custom_code: false }); if (url.startsWith('/api/runs/stats')) return undefined; - return Response.json([]); + return page([], 0); }); return posts; } @@ -99,3 +101,63 @@ test('cancel closes the dialog without starting anything', async () => { expect(navigation.router.replace.mock.calls).toEqual([['/runs/', { scroll: false }]]); expect(navigation.router.push).not.toHaveBeenCalled(); }); + +const rowText = (text: string) => + [...document.querySelectorAll('[role="row"]')].find((row) => row.textContent?.includes(text))?.textContent; + +test('every run is reachable: the pager fetches the next page, and a new filter starts at the first', async () => { + navigation.reset('/runs/'); + const newest = Array.from({ length: 25 }, (_, index) => created(`r${index}`, `new${index}`)); + const requested: string[] = []; + fakeFetch((url) => { + if (!url.startsWith('/api/runs?')) return undefined; + requested.push(url); + return url.includes('offset=25') ? page([created('r25', 'oldest')], 26) : page(newest, 26); + }); + const { rerender } = await render(); + await settle(); + expect(rowText('oldest')).toBeUndefined(); + + await click(document.querySelector('button[aria-label="Go to page 2"]')); + expect(rowText('oldest')).toContain('oldest'); + expect(rowText('new0')).toBeUndefined(); + + navigation.search = new URLSearchParams('status=failed'); + await rerender(); + await settle(); + + expect(requested).toEqual([ + '/api/runs?offset=0&limit=25', + '/api/runs?offset=25&limit=25', + '/api/runs?status=failed&offset=0&limit=25', + ]); +}); + +test('a poll that leaves the page shown past the last one moves back to the last page', async () => { + jest.useFakeTimers(); + navigation.reset('/runs/', 'status=running'); + const running = Array.from({ length: 26 }, (_, index) => created(`r${index}`, `env${index}`)); + const requested: string[] = []; + fakeFetch((url) => { + if (!url.startsWith('/api/runs?')) return undefined; + requested.push(url); + const offset = Number(new URL(url, 'http://localhost').searchParams.get('offset')); + return page(running.slice(offset, offset + 25), running.length); + }); + await render(); + await settle(); + await click(document.querySelector('button[aria-label="Go to page 2"]')); + expect(rowText('env25')).toContain('env25'); + + // One run finishes, so the next poll of page 2 finds it empty, with one page left. + running.pop(); + await act(async () => jest.advanceTimersByTime(2500)); + await settle(); + + expect(requested.slice(-2)).toEqual([ + '/api/runs?status=running&offset=25&limit=25', + '/api/runs?status=running&offset=0&limit=25', + ]); + expect(document.querySelectorAll('[role="row"]')).toHaveLength(26); + expect(rowText('env0')).toContain('env0'); +}); diff --git a/deepgym/console-ui/components/runs/runs-page.tsx b/deepgym/console-ui/components/runs/runs-page.tsx index a03f5ce3..f14c9c3e 100644 --- a/deepgym/console-ui/components/runs/runs-page.tsx +++ b/deepgym/console-ui/components/runs/runs-page.tsx @@ -15,10 +15,10 @@ import { RangeControl, useRange } from '@/components/ui/range-control'; import { Stat } from '@/components/ui/stat'; import { useApi } from '@/lib/api'; import { RANGES } from '@/lib/stats'; -import { RUN_MODES, type RunStats, type RunSummary } from '@/lib/types'; +import { RUN_MODES, type RunPage, type RunStats } from '@/lib/types'; import { useHotkey } from '@/lib/use-hotkey'; +import { usePagedApi } from '@/lib/use-paged-api'; import { useQueryParam } from '@/lib/use-query-param'; -import { isActive } from '@/lib/verdict'; import { NewRunDialog } from './new-run-dialog'; import { FailureModes, KpiTiles, ScoreDistribution } from './run-stats'; import { RunsTable } from './runs-table'; @@ -43,8 +43,8 @@ function FilterChip({ label, onClear }: { label: string; onClear: () => void }) } /** - * Stats for the chosen window (?range=) with deltas, and the runs table with status, provider, - * environment and eval-group filters, plus the New run dialog (?new=). + * Stats for the chosen window (?range=) with deltas, and the runs table, a server page at a time, + * with status, provider, environment and eval-group filters, plus the New run dialog (?new=). */ export function RunsPage() { const router = useRouter(); @@ -62,13 +62,12 @@ export function RunsPage() { if (provider) filters.set('provider', provider); if (environment) filters.set('environment', environment); if (group) filters.set('group_id', group); - const { data, error } = useApi(`/runs?${filters}&limit=500`, 2500); + const { data, error, paging } = usePagedApi('/runs', 2500, filters.toString()); // An eval group is one batch, so its stats cover all of it rather than a time window. const { data: stats } = useApi( `/runs/stats?${filters}${group ? '' : `&hours=${RANGES[range]}`}`, 5000, ); - const runs = data ?? []; return ( <> @@ -85,7 +84,11 @@ export function RunsPage() { } /> - +
@@ -113,11 +116,12 @@ export function RunsPage() { {environment && setEnvironment(null)} />} {group && setGroup(null)} />} - {runs.length} runs + {data?.total ?? 0} runs
router.push(`/runs/detail/?id=${run.id}`)} diff --git a/deepgym/console-ui/components/runs/runs-table.tsx b/deepgym/console-ui/components/runs/runs-table.tsx index 6882f702..7f9a6a39 100644 --- a/deepgym/console-ui/components/runs/runs-table.tsx +++ b/deepgym/console-ui/components/runs/runs-table.tsx @@ -3,7 +3,7 @@ import { RiPlayCircleLine } from '@remixicon/react'; import type { ColumnDef } from '@tanstack/react-table'; import { Chip } from '@/components/base/badges/chip'; -import { DataTable } from '@/components/ui/data-table'; +import { DataTable, type Paging } from '@/components/ui/data-table'; import { EmptyState } from '@/components/ui/empty-state'; import { ScoreBar } from '@/components/ui/score-bar'; import { PassBadge, RunStatusBadge } from '@/components/ui/status-badge'; @@ -27,13 +27,13 @@ const columns: ColumnDef[] = [ }, { id: 'score', - accessorFn: (run) => run.score ?? -1, + accessorKey: 'score', header: 'Score', cell: ({ row }) => , }, { id: 'passed', - accessorFn: (run) => (run.passed === null ? -1 : Number(run.passed)), + accessorKey: 'passed', header: 'Result', cell: ({ row }) => , }, @@ -65,7 +65,7 @@ const columns: ColumnDef[] = [ }, { id: 'duration', - accessorFn: (run) => run.duration_ms ?? -1, + accessorKey: 'duration_ms', header: 'Duration', cell: ({ row }) => formatDuration(row.original.duration_ms), meta: { align: 'end', className: 'font-mono text-text-secondary' }, @@ -81,21 +81,21 @@ const columns: ColumnDef[] = [ interface RunsTableProps { runs: RunSummary[]; + paging: Paging; loading: boolean; error?: string; onOpen: (run: RunSummary) => void; } -/** Runs, newest first; Enter or click opens the run. */ -export function RunsTable({ runs, loading, error, onOpen }: RunsTableProps) { +/** One server page of runs, newest first; Enter or click opens the run. */ +export function RunsTable({ runs, paging, loading, error, onOpen }: RunsTableProps) { return ( (open ? '/environments' : null); - const runs = useApi(open ? '/runs?limit=20' : null); - const episodes = useApi(open ? '/episodes' : null); + const runs = useApi(open ? '/runs?limit=20' : null); + const episodes = useApi(open ? '/episodes?limit=20' : null); const go = (href: string) => { onOpenChange(false); @@ -109,7 +109,7 @@ export function CommandPalette({ ))} - {runs.data?.map((run) => ( + {runs.data?.runs.map((run) => ( - {episodes.data?.slice(0, 20).map((episode) => ( + {episodes.data?.episodes.map((episode) => ( void; +} + interface DataTableProps { label: string; data: T[]; @@ -40,6 +47,11 @@ interface DataTableProps { initialSort?: SortingState; /** Rows per page; omit to show every row. */ pageSize?: number; + /** + * Pages fetched from the server, instead of `pageSize`: `data` is the page at `pageIndex`. + * Sorting is off, since it could only reorder that one page. + */ + serverPaging?: Paging; empty?: ReactNode; } @@ -54,6 +66,7 @@ export function DataTable({ onRowAction, initialSort = [], pageSize, + serverPaging, empty, }: DataTableProps) { const [sorting, setSorting] = useState(initialSort); @@ -62,6 +75,7 @@ export function DataTable({ columns, state: { sorting }, onSortingChange: setSorting, + enableSorting: !serverPaging, getRowId: (row) => row.id, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), @@ -71,7 +85,11 @@ export function DataTable({ autoResetPageIndex: true, }); const sort = sorting[0]; - const pages = table.getPageCount(); + const paging: Paging = serverPaging ?? { + pageIndex: table.getState().pagination.pageIndex, + pageCount: table.getPageCount(), + onPageChange: (pageIndex) => table.setPageIndex(pageIndex), + }; return (
@@ -135,12 +153,12 @@ export function DataTable({ )} - {pages > 1 && ( + {paging.pageCount > 1 && (
table.setPageIndex(page - 1)} + page={paging.pageIndex + 1} + totalPages={paging.pageCount} + onChange={(page) => paging.onPageChange(page - 1)} />
)} diff --git a/deepgym/console-ui/components/ui/status-badge.tsx b/deepgym/console-ui/components/ui/status-badge.tsx index 39e5cb32..c054ba68 100644 --- a/deepgym/console-ui/components/ui/status-badge.tsx +++ b/deepgym/console-ui/components/ui/status-badge.tsx @@ -1,6 +1,6 @@ import { Chip, type ChipProps } from '@/components/base/badges/chip'; import { formatScore } from '@/lib/format'; -import type { EnvKind, RunStatus } from '@/lib/types'; +import type { EnvKind, EpisodeSummary, RunStatus } from '@/lib/types'; import { scoreTone, type Tone } from '@/lib/verdict'; import { cx } from '@/utils/cx'; @@ -34,6 +34,26 @@ export function PassBadge({ passed }: { passed: boolean | null }) { ); } +/** + * Pass/fail pill, or "no verdict" and its error type when the verifier or sandbox failed instead + * of judging, so that 0 is never shown as a fail. + */ +export function VerdictBadge({ + verdict, +}: { + verdict: Pick; +}) { + if (verdict.has_verdict !== false) return ; + return ( + + + no verdict + + {verdict.error_type} + + ); +} + const TONE_COLOR: Record = { good: 'lime', warn: 'yellow', bad: 'rose' }; /** A score in [0, 1] as a pill coloured by its band (green from 0.8, amber from 0.5, red below). */ diff --git a/deepgym/console-ui/lib/types.ts b/deepgym/console-ui/lib/types.ts index 1cadb610..f6134960 100644 --- a/deepgym/console-ui/lib/types.ts +++ b/deepgym/console-ui/lib/types.ts @@ -66,6 +66,13 @@ export interface RunSummary { finished_at: string | null; } +export interface RunPage { + /** Newest first. */ + runs: RunSummary[]; + /** Matching runs across every page. */ + total: number; +} + export interface RunDetail extends RunSummary { solution: string; error: string | null; @@ -98,11 +105,23 @@ export interface EpisodeSummary { environment: string; steps: number; total_reward: number; + /** Null when recorded without a result, or its result has no verdict. */ score: number | null; passed: boolean | null; + /** False when the verifier or sandbox failed instead of judging; null when recorded without a result. */ + has_verdict: boolean | null; + /** Always set when there is no verdict. */ + error_type: string | null; created_at: string; } +export interface EpisodePage { + /** Newest first. */ + episodes: EpisodeSummary[]; + /** Matching episodes across every page. */ + total: number; +} + export interface EpisodeDetail extends EpisodeSummary { frames: Frame[]; result: RunResult | null; diff --git a/deepgym/console-ui/lib/use-paged-api.ts b/deepgym/console-ui/lib/use-paged-api.ts new file mode 100644 index 00000000..78830c44 --- /dev/null +++ b/deepgym/console-ui/lib/use-paged-api.ts @@ -0,0 +1,31 @@ +'use client'; + +import { useState } from 'react'; +import { useApi } from './api'; + +const PAGE_SIZE = 25; + +/** + * Fetch `path` a server page at a time, refetching every `intervalMs`, with the paging a DataTable + * takes as `serverPaging`. The page is kept with the `query` it pages through, so a new query + * starts at the first page. + */ +export function usePagedApi(path: string, intervalMs: number, query = '') { + const [page, setPage] = useState({ query, index: 0 }); + const pageIndex = page.query === query ? page.index : 0; + const params = new URLSearchParams(query); + params.set('offset', String(pageIndex * PAGE_SIZE)); + params.set('limit', String(PAGE_SIZE)); + const { data, error } = useApi(`${path}?${params}`, intervalMs); + const pageCount = Math.ceil((data?.total ?? 0) / PAGE_SIZE); + // Rows that finish or go away between polls can leave this page past the last one, empty and + // with no pager to leave it: move to the last page there is. Set while rendering, so React + // re-renders at once instead of showing the empty page first. + const lastPage = Math.max(pageCount - 1, 0); + if (data && pageIndex > lastPage) setPage({ query, index: lastPage }); + return { + data, + error, + paging: { pageIndex, pageCount, onPageChange: (index: number) => setPage({ query, index }) }, + }; +} diff --git a/deepgym/docs/authoring/qa-gate.md b/deepgym/docs/authoring/qa-gate.md index 7af1e3bf..bebad1f0 100644 --- a/deepgym/docs/authoring/qa-gate.md +++ b/deepgym/docs/authoring/qa-gate.md @@ -89,9 +89,11 @@ Where mutants can run: (every sample earned the same), since group-relative RL learns nothing from equal rewards. Partial rewards are variance: `[0.2, 0.8]` is neither too hard nor flat. -Rate limits (429), timeouts and 5xx are retried with backoff; if a sample never comes -back, or the verifier couldn't judge one, the check is inconclusive. A refused request (a -bad key or model) stops the run. +The probe sends no output limit. If the endpoint still cuts a sample off at its own +(`finish_reason` `length`), that sample is no answer, so the check is inconclusive +instead of reading it as a failure. Rate limits (429), timeouts and 5xx are retried with +backoff; if a sample never comes back, or the verifier couldn't judge one, the check is +inconclusive. A refused request (a bad key or model) stops the run. `--model` runs model-generated code, so with `--mode local` (the default) or `auto` (which can fall back to local) it refuses to start unless you pass `--allow-local-exec`. Use a diff --git a/deepgym/docs/environments/importers.md b/deepgym/docs/environments/importers.md index 3f4ffff3..cb73447a 100644 --- a/deepgym/docs/environments/importers.md +++ b/deepgym/docs/environments/importers.md @@ -185,7 +185,8 @@ deepgym harbor import ./my-tasks --register mine deepgym qa mine/hello-world --mode docker # reference solve.sh passes, no-op fails ``` -Remote datasets are shallow-cloned once into `~/.deepgym/benchmarks/harbor/` and reused. +Remote datasets are shallow-cloned once into `~/.deepgym/benchmarks/harbor/`, one folder +per URL, and reused only while git confirms the folder is a clone of that URL. Each task is an environment whose model output is a bash script: ```python diff --git a/deepgym/docs/training/lm-eval.md b/deepgym/docs/training/lm-eval.md index 4d2b5ed0..3383d7e2 100644 --- a/deepgym/docs/training/lm-eval.md +++ b/deepgym/docs/training/lm-eval.md @@ -12,7 +12,11 @@ import lm_eval from deepgym import load_environment from deepgym.integrations.lm_eval import make_lm_eval_task -tasks = [make_lm_eval_task(load_environment(n), n) for n in ['coin_change', 'two_sum']] +# max_gen_toks: the model's maximum output (Qwen2.5 generates up to 8K tokens). +tasks = [ + make_lm_eval_task(load_environment(n), n, max_gen_toks=8192) + for n in ['coin_change', 'two_sum'] +] results = lm_eval.simple_evaluate( model='hf', model_args='pretrained=Qwen/Qwen2.5-0.5B-Instruct', tasks=tasks ) @@ -23,6 +27,10 @@ results = lm_eval.simple_evaluate( - lm-eval has no registry for Python tasks, so pass the task objects; `lm_eval --tasks` can't name them. - The last fenced code block of the generation runs, or all of it if there is none. +- Generation runs to the model's maximum output, which you pass as `max_gen_toks`: lm-eval + needs a number, its backends would otherwise cut every answer at 256, and only you know + your model's real maximum, so there is no default. lm-eval gives the scorer only the + text, so a generation that reached the maximum is scored as written. - Pass `dg=` to pick the client that runs the verifier; without one, the task uses `DeepGym(mode='auto')`. diff --git a/deepgym/scripts/_eval_summary.py b/deepgym/scripts/_eval_summary.py new file mode 100644 index 00000000..0673ab82 --- /dev/null +++ b/deepgym/scripts/_eval_summary.py @@ -0,0 +1,31 @@ +"""Summaries of eval-script records: what an average may count, and what it may not.""" + +from __future__ import annotations + +from typing import Any + + +def summarize(records: list[dict[str, Any]]) -> dict[str, Any]: + """Pass rate and mean score over the runs that measure what was asked: judged runs of + a whole answer from the requested source. A model reply cut off at an output limit, + one of unknown completion (an old cache), a reference fallback or a run with no + verdict is counted apart, never averaged in.""" + measured = [ + record + for record in records + if record['has_verdict'] + and record['complete'] is True + and record.get('answer_source') != 'reference_fallback' + ] + return { + 'count': len(records), + 'measured': len(measured), + 'passed': sum(bool(record['passed']) for record in measured), + 'avg_score': round(sum(record['score'] for record in measured) / len(measured), 4) + if measured + else None, + 'truncated': sum(record['complete'] is False for record in records), + 'unknown_completion': sum(record['complete'] is None for record in records), + 'reference_fallback': sum(r.get('answer_source') == 'reference_fallback' for r in records), + 'no_verdict': sum(not record['has_verdict'] for record in records), + } diff --git a/deepgym/scripts/generate_cyberbench_seeds.py b/deepgym/scripts/generate_cyberbench_seeds.py index b7cbc296..186df9bc 100755 --- a/deepgym/scripts/generate_cyberbench_seeds.py +++ b/deepgym/scripts/generate_cyberbench_seeds.py @@ -69,15 +69,15 @@ def main() -> int: parser.add_argument('--output', default='data/cyberbench/seed_specs.jsonl') parser.add_argument('--use-zai', action='store_true', help='Use Z.ai/GLM to enrich specs') parser.add_argument('--temperature', type=float, default=0.2) - parser.add_argument('--max-tokens', type=int, default=1600) - parser.add_argument('--zai-timeout', type=float, default=30.0) + parser.add_argument('--max-tokens', type=int) + parser.add_argument('--zai-timeout', type=float, help='seconds; default: no limit') parser.add_argument('--fail-on-invalid', action='store_true') args = parser.parse_args() _load_dotenv(ROOT / '.env') rows = _load_hf_rows(args.repo_id, args.split, args.count) client = ZaiChatClient.from_env() if args.use_zai else None - if client is not None: + if client is not None and args.zai_timeout is not None: client.timeout = args.zai_timeout specs = [] @@ -98,7 +98,9 @@ def main() -> int: temperature=args.temperature, thinking='enabled', ) - spec = spec_from_glm_json(response, row=row, index=index) + if response.whole is not True: + raise ValueError(f'not a whole reply (finish_reason {response.finish_reason})') + spec = spec_from_glm_json(response.text, row=row, index=index) except Exception as exc: spec = cyber_seed_from_hf_row(row, index=index) zai_errors.append( @@ -121,8 +123,8 @@ def main() -> int: 'repo_id': args.repo_id, 'rows_read': len(rows), 'specs_written': len(specs), - 'invalid': invalid[:10], - 'zai_errors': zai_errors[:10], + 'invalid': invalid, + 'zai_errors': zai_errors, 'output': str(output), 'used_zai': client is not None, 'zai_successes': len(rows) - len(zai_errors) if client is not None else 0, diff --git a/deepgym/scripts/run_blackbox_scan.py b/deepgym/scripts/run_blackbox_scan.py index 6284a2a9..2f02db7c 100644 --- a/deepgym/scripts/run_blackbox_scan.py +++ b/deepgym/scripts/run_blackbox_scan.py @@ -21,9 +21,14 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent / 'src')) from deepgym import DeepGym, Environment -from deepgym.adversarial import PASS_THRESHOLD, BlackBoxAdversarialTester, BlackBoxReport +from deepgym.adversarial import ( + PASS_THRESHOLD, + BlackBoxAdversarialTester, + BlackBoxReport, + fingerprint_verifier, + grading_fingerprint, +) from deepgym.exploit_db import ExploitDB, ExploitRecord -from deepgym.reward_qa import fingerprint_verifier logger = logging.getLogger(__name__) @@ -70,7 +75,7 @@ def _scan_one( A status line: skipped, INCONCLUSIVE (not saved, so the next scan retries it), EXPLOITABLE or ROBUST. """ - verifier_hash = fingerprint_verifier(env.verifier_source) + verifier_hash = grading_fingerprint(env) config = _attack_config(tester, env.task) if db.current( name, @@ -84,7 +89,7 @@ def _scan_one( report = tester.test(env) elapsed = time.time() - start if report.status == 'inconclusive': - return f'INCONCLUSIVE, not saved ({"; ".join(report.problems[:3])}, {elapsed:.1f}s)' + return f'INCONCLUSIVE, not saved ({"; ".join(report.problems)}, {elapsed:.1f}s)' db.save(_blackbox_record(name, benchmark, report, verifier_hash, config)) return ( f'{report.status.upper()} (best={report.best_exploit_score:.2f}, ' diff --git a/deepgym/scripts/run_cybergym_artifact_eval.py b/deepgym/scripts/run_cybergym_artifact_eval.py index 2a01ab59..0aa0ce49 100755 --- a/deepgym/scripts/run_cybergym_artifact_eval.py +++ b/deepgym/scripts/run_cybergym_artifact_eval.py @@ -22,13 +22,15 @@ if str(SRC) not in sys.path: sys.path.insert(0, str(SRC)) +from _eval_summary import summarize # noqa: E402 + from deepgym import DeepGym # noqa: E402 from deepgym.cybergym_artifacts import ( # noqa: E402 CyberGymPatchEnvironment, download_cybergym_artifacts, load_cybergym_rows, ) -from deepgym.integrations.zai import ZaiChatClient # noqa: E402 +from deepgym.integrations.zai import Completion, ZaiChatClient, cached_completion # noqa: E402 def _load_dotenv(path: Path) -> None: @@ -51,20 +53,20 @@ def _glm_patch( env: CyberGymPatchEnvironment, cache_path: Path, args: argparse.Namespace, -) -> tuple[str, str]: - if cache_path.exists() and not args.refresh_cache: - return cache_path.read_text(encoding='utf-8'), 'cache' +) -> tuple[Completion, str]: prompt = env.task + '\n\nReturn only a unified diff patch.' - answer = client.complete_prompt( - prompt, - system='You repair local synthetic vulnerable repositories. Return a unified diff only.', - max_tokens=args.max_tokens, - temperature=args.temperature, - thinking=args.thinking, + system = 'You repair local synthetic vulnerable repositories. Return a unified diff only.' + return cached_completion( + cache_path, + lambda: client.complete_prompt( + prompt, + system=system, + max_tokens=args.max_tokens, + temperature=args.temperature, + thinking=args.thinking, + ), + refresh=args.refresh_cache, ) - cache_path.parent.mkdir(parents=True, exist_ok=True) - cache_path.write_text(answer, encoding='utf-8') - return answer, 'glm' def _run_one(index: int, row: dict[str, Any], args: argparse.Namespace) -> dict[str, Any]: @@ -81,48 +83,61 @@ def _run_one(index: int, row: dict[str, Any], args: argparse.Namespace) -> dict[ answer_source = args.answer_source error = '' + finish_reason: str | None = None + complete: bool | None = True # a reference patch is whole + answer: str | None = None if args.answer_source == 'reference': if artifacts.patch is None: - answer = '' error = 'missing reference patch' else: answer = artifacts.patch.read_text(encoding='utf-8', errors='replace') else: try: client = ZaiChatClient.from_env() - client.timeout = args.zai_timeout - answer, answer_source = _glm_patch( + if args.zai_timeout is not None: + client.timeout = args.zai_timeout + completion, answer_source = _glm_patch( client, env, Path(args.cache_dir) / f'{_safe_task_id(task_id)}.diff', args, ) + answer, finish_reason = completion.text, completion.finish_reason + complete = completion.whole except Exception as exc: error = f'{type(exc).__name__}: {exc}' if args.fallback_to_reference and artifacts.patch is not None: answer = artifacts.patch.read_text(encoding='utf-8', errors='replace') answer_source = 'reference_fallback' - else: - answer = '' - dg = DeepGym(mode=args.mode) - result = dg.run(env, answer, timeout=args.timeout) - return { + record: dict[str, Any] = { 'index': index, 'task_id': task_id, 'project': row.get('project_name', ''), 'language': row.get('project_language', ''), 'answer_source': answer_source, 'answer_error': error, - 'score': result.score, - 'passed': result.passed, - 'details': result.output, - 'reward_components': result.reward_components, - 'metrics': result.metrics, - 'cases': [case.model_dump() for case in result.cases] if result.cases else [], - 'sandbox_id': result.sandbox_id, - 'elapsed_ms': round((time.perf_counter() - started) * 1000, 2), + 'finish_reason': finish_reason, + 'complete': complete, + 'score': None, + 'passed': None, + 'has_verdict': False, } + if answer is not None: # no answer, no run: a failed model call is never a scored 0 + result = DeepGym(mode=args.mode).run(env, answer, timeout=args.timeout) + record |= { + 'score': result.score, + 'passed': result.passed, + 'has_verdict': result.has_verdict, + 'error_type': result.error_type, + 'details': result.output, + 'reward_components': result.reward_components, + 'metrics': result.metrics, + 'cases': [case.model_dump() for case in result.cases] if result.cases else [], + 'sandbox_id': result.sandbox_id, + } + record['elapsed_ms'] = round((time.perf_counter() - started) * 1000, 2) + return record def main() -> int: @@ -140,10 +155,10 @@ def main() -> int: parser.add_argument('--output', default='data/cyberbench/top100_artifact_eval.jsonl') parser.add_argument('--cache-dir', default='data/cyberbench/glm_patch_cache') parser.add_argument('--refresh-cache', action='store_true') - parser.add_argument('--max-tokens', type=int, default=4096) + parser.add_argument('--max-tokens', type=int) parser.add_argument('--temperature', type=float, default=0.1) parser.add_argument('--thinking', choices=['enabled', 'disabled'], default='enabled') - parser.add_argument('--zai-timeout', type=float, default=60.0) + parser.add_argument('--zai-timeout', type=float, help='seconds; default: no limit') args = parser.parse_args() _load_dotenv(ROOT / '.env') @@ -173,13 +188,9 @@ def main() -> int: encoding='utf-8', ) summary = { - 'count': len(records), + **summarize(records), 'mode': args.mode, 'answer_source': args.answer_source, - 'passed': sum(bool(record['passed']) for record in records), - 'avg_score': round(sum(float(record['score']) for record in records) / len(records), 4) - if records - else 0.0, 'output': str(output_path), } summary_path = output_path.with_suffix('.summary.json') diff --git a/deepgym/scripts/run_cybergym_deepgym_eval.py b/deepgym/scripts/run_cybergym_deepgym_eval.py index 9c7630e7..0dc93481 100755 --- a/deepgym/scripts/run_cybergym_deepgym_eval.py +++ b/deepgym/scripts/run_cybergym_deepgym_eval.py @@ -25,11 +25,13 @@ if str(SRC) not in sys.path: sys.path.insert(0, str(SRC)) +from _eval_summary import summarize # noqa: E402 + from deepgym import DeepGym, Environment # noqa: E402 from deepgym.cyberbench import classify_vulnerability_family # noqa: E402 -from deepgym.integrations.zai import ZaiChatClient # noqa: E402 +from deepgym.integrations.zai import Completion, ZaiChatClient, cached_completion # noqa: E402 -_VERIFIER_CODE = r''' +_VERIFIER_CODE = r""" import json import re @@ -124,7 +126,7 @@ def verify(solution_path, test_cases_path=None): test_cases_path = sys.argv[2] if len(sys.argv) > 2 else None print(json.dumps(verify(sys.argv[1], test_cases_path))) -''' +""" def _load_dotenv(path: Path) -> None: @@ -206,48 +208,34 @@ def _completion_cache_path(cache_dir: Path, task_id: str) -> Path: return cache_dir / f'{safe}.txt' -def _call_glm(client: ZaiChatClient, env: Environment, *, cache_path: Path, args: argparse.Namespace) -> tuple[str, str]: - if cache_path.exists() and not args.refresh_cache: - return cache_path.read_text(encoding='utf-8'), 'cache' +def _call_glm( + client: ZaiChatClient, env: Environment, *, cache_path: Path, args: argparse.Namespace +) -> tuple[Completion, str]: prompt = ( - 'Solve this defensive/local CyberBench-RL task. Return strict JSON only.\n\n' - + env.task - ) - last_error = '' - for attempt in range(args.retries + 1): - try: - answer = client.complete_prompt( - prompt, - system='You are a defensive cybersecurity benchmark solver. Return strict JSON only.', - max_tokens=args.max_tokens, - temperature=args.temperature, - thinking=args.thinking, - ) - cache_path.parent.mkdir(parents=True, exist_ok=True) - cache_path.write_text(answer, encoding='utf-8') - return answer, 'glm' - except Exception as exc: - last_error = f'{type(exc).__name__}: {exc}' - if attempt < args.retries: - time.sleep(args.retry_sleep * (attempt + 1)) - raise RuntimeError(last_error) - - -def _fallback_answer(expected: dict[str, Any], error: str) -> str: - return json.dumps( - { - 'task_id': expected['task_id'], - 'project': expected['project'], - 'language': expected['language'], - 'family': expected['family'], - 'vulnerability_class': expected['family'], - 'evidence': expected['description'], - 'remediation': 'Patch the synthetic vulnerable code, add regression tests, validate inputs and bounds, and keep the task local-only.', - 'glm_error': error, - }, - ensure_ascii=False, + 'Solve this defensive/local CyberBench-RL task. Return strict JSON only.\n\n' + env.task ) + def complete() -> Completion: + attempt = 0 + while True: + try: + return client.complete_prompt( + prompt, + system=( + 'You are a defensive cybersecurity benchmark solver. Return strict JSON only.' + ), + max_tokens=args.max_tokens, + temperature=args.temperature, + thinking=args.thinking, + ) + except Exception: + if attempt == args.retries: + raise + attempt += 1 + time.sleep(args.retry_sleep * attempt) + + return cached_completion(cache_path, complete, refresh=args.refresh_cache) + def main() -> int: parser = argparse.ArgumentParser(description='Run top-N CyberGym HF rows through DeepGym.') @@ -259,93 +247,74 @@ def main() -> int: parser.add_argument('--output', default='data/cyberbench/top100_eval.jsonl') parser.add_argument('--cache-dir', default='data/cyberbench/glm_cache') parser.add_argument('--refresh-cache', action='store_true') - parser.add_argument('--max-tokens', type=int, default=700) + parser.add_argument('--max-tokens', type=int) parser.add_argument('--temperature', type=float, default=0.1) parser.add_argument('--thinking', choices=['enabled', 'disabled'], default='enabled') - parser.add_argument('--zai-timeout', type=float, default=45.0) + parser.add_argument('--zai-timeout', type=float, help='seconds; default: no limit') parser.add_argument('--retries', type=int, default=1) parser.add_argument('--retry-sleep', type=float, default=8.0) - parser.add_argument('--fallback-on-glm-error', action='store_true') - parser.add_argument('--max-glm-calls', type=int, default=1000000) args = parser.parse_args() _load_dotenv(ROOT / '.env') rows = _load_rows(args.repo_id, args.split, args.count, args.start_index) client = ZaiChatClient.from_env() - client.timeout = args.zai_timeout - glm_calls = 0 - glm_attempts = 0 + if args.zai_timeout is not None: + client.timeout = args.zai_timeout dg = DeepGym(mode=args.mode) output_path = ROOT / args.output output_path.parent.mkdir(parents=True, exist_ok=True) cache_dir = ROOT / args.cache_dir - passed = 0 - total_score = 0.0 - glm_errors = 0 + records: list[dict[str, Any]] = [] with output_path.open('w', encoding='utf-8') as out: for local_index, row in enumerate(rows): index = args.start_index + local_index env, expected = _make_task(row, index) - cache_path = _completion_cache_path(cache_dir, expected['task_id']) - source = 'glm' - error = '' - try: - if glm_attempts >= args.max_glm_calls and not cache_path.exists(): - raise RuntimeError('GLM call cap reached; using deterministic fallback') - if not cache_path.exists(): - glm_attempts += 1 - answer, source = _call_glm(client, env, cache_path=cache_path, args=args) - if source == 'glm': - glm_calls += 1 - except Exception as exc: - glm_errors += 1 - error = str(exc) - if not args.fallback_on_glm_error: - record = { - 'index': index, - 'task_id': expected['task_id'], - 'glm_error': error, - 'score': 0.0, - 'passed': False, - } - out.write(json.dumps(record, ensure_ascii=False) + '\n') - print(json.dumps(record, ensure_ascii=False), flush=True) - continue - answer = _fallback_answer(expected, error) - source = 'fallback' - - result = dg.run(env, answer) - passed += int(result.passed) - total_score += result.score - record = { + record: dict[str, Any] = { 'index': index, 'task_id': expected['task_id'], 'project': expected['project'], 'language': expected['language'], 'family': expected['family'], - 'answer_source': source, - 'glm_error': error, - 'score': result.score, - 'passed': result.passed, - 'details': result.output, - 'reward_components': result.reward_components, - 'cases': [case.model_dump() for case in result.cases] if result.cases else [], + 'glm_error': '', + 'finish_reason': None, + 'complete': None, + 'score': None, + 'passed': None, + 'has_verdict': False, } + try: + cache_path = _completion_cache_path(cache_dir, expected['task_id']) + completion, record['answer_source'] = _call_glm( + client, env, cache_path=cache_path, args=args + ) + except Exception as exc: # no answer, no run: a failed call is never a scored 0 + record['glm_error'] = f'{type(exc).__name__}: {exc}' + else: + finish_reason = completion.finish_reason + result = dg.run(env, completion.text) + record |= { + 'finish_reason': finish_reason, + 'complete': completion.whole, + 'score': result.score, + 'passed': result.passed, + 'has_verdict': result.has_verdict, + 'error_type': result.error_type, + 'details': result.output, + 'reward_components': result.reward_components, + 'cases': [case.model_dump() for case in result.cases] if result.cases else [], + } + records.append(record) out.write(json.dumps(record, ensure_ascii=False) + '\n') print(json.dumps(record, ensure_ascii=False), flush=True) summary = { + **summarize(records), 'repo_id': args.repo_id, 'split': args.split, 'start_index': args.start_index, - 'count': len(rows), 'mode': args.mode, - 'passed': passed, - 'avg_score': round(total_score / len(rows), 4) if rows else 0.0, - 'glm_errors': glm_errors, - 'glm_calls': glm_calls, - 'glm_attempts': glm_attempts, + 'glm_errors': sum(bool(record['glm_error']) for record in records), 'output': str(output_path), } print(json.dumps({'summary': summary}, ensure_ascii=False, indent=2)) diff --git a/deepgym/scripts/run_exploit_scan.py b/deepgym/scripts/run_exploit_scan.py index 44e917fa..fffd4665 100644 --- a/deepgym/scripts/run_exploit_scan.py +++ b/deepgym/scripts/run_exploit_scan.py @@ -16,9 +16,9 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent / 'src')) from deepgym import DeepGym, Environment -from deepgym.adversarial import LLMAdversarialTester +from deepgym.adversarial import LLMAdversarialTester, fingerprint_verifier, grading_fingerprint from deepgym.exploit_db import ExploitDB -from deepgym.reward_qa import build_exploit_record, fingerprint_verifier +from deepgym.reward_qa import build_exploit_record logger = logging.getLogger(__name__) @@ -67,7 +67,8 @@ def scan_benchmark( verifier_code = verifier_path.read_text(encoding='utf-8') task = task_path.read_text(encoding='utf-8') if task_path.exists() else d.name - verifier_hash = fingerprint_verifier(verifier_code) + env = Environment(task=task, verifier_code=verifier_code) + verifier_hash = grading_fingerprint(env) # The attack's inputs: a changed task or setup must not reuse an old verdict. config = f'llm; model={model}; max_attacks={max_attacks}; task={fingerprint_verifier(task)}' if db.current( @@ -80,8 +81,6 @@ def scan_benchmark( print(f' [{i + 1}/{len(env_dirs)}] {d.name}: already tested, skipping') continue - env = Environment(task=task, verifier_code=verifier_code) - print(f' [{i + 1}/{len(env_dirs)}] {d.name}...', end=' ', flush=True) start = time.time() diff --git a/deepgym/scripts/smoke_zai_deepgym.py b/deepgym/scripts/smoke_zai_deepgym.py index 647251e6..3f05acf5 100755 --- a/deepgym/scripts/smoke_zai_deepgym.py +++ b/deepgym/scripts/smoke_zai_deepgym.py @@ -43,7 +43,7 @@ def main() -> int: parser = argparse.ArgumentParser(description='Smoke-test a DeepGym env with Z.ai/GLM.') parser.add_argument('--env', default='cyber/cve_2021_44228_log_triage') parser.add_argument('--mode', choices=['local', 'auto', 'daytona'], default='local') - parser.add_argument('--max-tokens', type=int, default=2048) + parser.add_argument('--max-tokens', type=int) parser.add_argument('--temperature', type=float, default=0.1) parser.add_argument('--thinking', choices=['enabled', 'disabled'], default='enabled') args = parser.parse_args() @@ -64,9 +64,10 @@ def main() -> int: ) dg = DeepGym(mode=args.mode) - result = dg.run(env, completion) + result = dg.run(env, completion.text) print(f'env={args.env}') print(f'model={client.model}') + print(f'finish_reason={completion.finish_reason}') print(f'score={result.score:.3f} passed={result.passed}') print(f'details={result.output}') if result.reward_components: diff --git a/deepgym/src/deepgym/adversarial.py b/deepgym/src/deepgym/adversarial.py index 6dc31ad6..715b10bb 100644 --- a/deepgym/src/deepgym/adversarial.py +++ b/deepgym/src/deepgym/adversarial.py @@ -16,13 +16,14 @@ import ast import builtins +import hashlib import json import keyword import logging import os import re import textwrap -from typing import TYPE_CHECKING, Literal, get_args +from typing import TYPE_CHECKING, Any, Literal, get_args from pydantic import BaseModel, Field @@ -34,6 +35,73 @@ logger = logging.getLogger(__name__) +# Opus 5.5's maximum output (see deepgym.agents.providers). A model with a smaller one +# says so in its error, and the request is made again at that size. +_CLAUDE_MAX_TOKENS = 128_000 +_STATED_MAX = re.compile(r'> (\d+), which is the maximum allowed number of output tokens') + + +def fingerprint_verifier(verifier_code: str) -> str: + """Return a stable short hash of a verifier's code.""" + return hashlib.sha256(verifier_code.encode('utf-8')).hexdigest()[:16] + + +def environment_id(env: Environment) -> str: + """Return a stable id for *env*: its name, else a digest of its whole task. + + Its identity, not its content: a changed verifier keeps the id, so a result stored for + the old one is found and dropped (see :func:`grading_fingerprint` for the version). + """ + return env.name or hashlib.sha256(env.task.encode('utf-8')).hexdigest()[:16] + + +def grading_fingerprint(env: Environment) -> str: + """Return a short hash of the whole definition of *env*, so it covers every input that + grades it: verifier, test cases, support files, and whatever a subclass grades with + (Harbor's test scripts, which its placeholder verifier doesn't show).""" + from deepgym.traces.schema import environment_sha256 + + return environment_sha256(env)[:16] + + +def claude_reply(client: Any, model: str, prompt: str) -> str: + """Return Claude's whole reply to *prompt*, generated up to the model's own maximum. + + Streamed, as the SDK requires for long outputs, and taken only once message_stop + arrives: a stream that just ends leaves the SDK a partial message. A model whose + maximum is below Opus 5.5's is asked again at the maximum its error states. + + Raises: + DeepGymError: If the request fails, the stream ended before message_stop, or the + reply stopped at the output limit (a list cut off either way isn't the model's + whole answer). + """ + max_tokens = _CLAUDE_MAX_TOKENS + try: + while True: + try: + with client.messages.stream( + model=model, + max_tokens=max_tokens, + messages=[{'role': 'user', 'content': prompt}], + ) as stream: + events = [event.type for event in stream] # all of them: a whole message + message = stream.get_final_message() + break + except Exception as exc: + stated = _STATED_MAX.search(str(exc)) + if stated is None or int(stated.group(1)) >= max_tokens: + raise + max_tokens = int(stated.group(1)) + except Exception as exc: + raise DeepGymError(f'Anthropic API call failed: {type(exc).__name__}: {exc}') from exc + if 'message_stop' not in events: + raise DeepGymError(f'The {model} reply stream ended before it completed') + if message.stop_reason == 'max_tokens': + raise DeepGymError(f'{model} stopped at its {max_tokens}-token output limit mid-reply') + return ''.join(block.text for block in message.content if block.type == 'text') + + # --------------------------------------------------------------------------- # Default attack strategies # --------------------------------------------------------------------------- @@ -245,7 +313,7 @@ def from_results(cls, env: Environment, results: list[AttackResult]) -> Adversar exploits = sum(result.exploited for result in results) inconclusive = sum(result.inconclusive for result in results) return cls( - environment=env.task[:120], + environment=env.task, attacks_run=len(results), exploits_found=exploits, inconclusive=inconclusive, @@ -648,9 +716,9 @@ def _execute_attack( f'error_type={result.error_type}' ] if result.output: - detail_lines.append(result.output[:500]) + detail_lines.append(result.output) if result.stderr: - detail_lines.append(f'stderr: {result.stderr[:300]}') + detail_lines.append(f'stderr: {result.stderr}') details = '\n'.join(detail_lines) if result.error_type in inconclusive_errors: return _not_run(strategy, f'Attack did not reach the verifier: {details}') @@ -804,16 +872,7 @@ def _generate_llm_exploits( max_attacks=max_attacks, ) - try: - client = Anthropic(api_key=resolved_key) - message = client.messages.create( - model=model, - max_tokens=4096, - messages=[{'role': 'user', 'content': prompt}], - ) - response_text = message.content[0].text - except Exception as exc: - raise DeepGymError(f'Anthropic API call failed: {type(exc).__name__}: {exc}') from exc + response_text = claude_reply(Anthropic(api_key=resolved_key), model, prompt) exploits = _parse_exploits(response_text) logger.info('LLM generated %d exploits for: %s', len(exploits), env.task[:80]) @@ -1154,7 +1213,7 @@ def test(self, env: Environment) -> BlackBoxReport: problems.append('no attempt ran') status = 'exploitable' if exploitable else 'inconclusive' if problems else 'robust' return BlackBoxReport( - verifier_id=env.task[:80], + verifier_id=environment_id(env), status=status, exploitable=exploitable, problems=problems, @@ -1201,15 +1260,7 @@ def _call_llm( Raises: DeepGymError: If the API call fails. """ - try: - message = client.messages.create( - model=self._model, - max_tokens=4096, - messages=[{'role': 'user', 'content': prompt}], - ) - response_text = message.content[0].text - except Exception as exc: - raise DeepGymError(f'Anthropic API call failed: {type(exc).__name__}: {exc}') from exc + response_text = claude_reply(client, self._model, prompt) parts = _EXPLOIT_SEPARATOR.split(response_text) codes: list[str] = [] diff --git a/deepgym/src/deepgym/agents/providers.py b/deepgym/src/deepgym/agents/providers.py index 6837105b..5f12e5dc 100644 --- a/deepgym/src/deepgym/agents/providers.py +++ b/deepgym/src/deepgym/agents/providers.py @@ -219,5 +219,5 @@ def api_error(exc: Exception) -> str: error = body.get('error', body) if isinstance(body, dict) else None detail = error.get('message') if isinstance(error, dict) else None status_text = f' {status}' if status else '' - detail_text = f': {str(detail)[:300]}' if detail else '' + detail_text = f': {detail}' if detail else '' return f'{type(exc).__name__}{status_text}{detail_text}' diff --git a/deepgym/src/deepgym/api/routes.py b/deepgym/src/deepgym/api/routes.py index ac782961..4fd2a97c 100644 --- a/deepgym/src/deepgym/api/routes.py +++ b/deepgym/src/deepgym/api/routes.py @@ -48,7 +48,7 @@ ) from deepgym.benchmark_ops import build_benchmark_audit from deepgym.core import DeepGym -from deepgym.exceptions import DeepGymError, SandboxError, VerifierError +from deepgym.exceptions import DeepGymError, SandboxError, SessionClosedError, VerifierError from deepgym.models import ( Action, BatchJob, @@ -133,6 +133,8 @@ def _http_errors( """Translate DeepGym errors into HTTP exceptions.""" try: yield + except SessionClosedError as exc: # deleted while this request waited for it + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc except VerifierError as exc: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, @@ -475,18 +477,22 @@ def create_session( raise HTTPException( status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail='Session limit reached' ) - try: - with _http_errors(fallback_status=status.HTTP_400_BAD_REQUEST): + with _http_errors(fallback_status=status.HTTP_400_BAD_REQUEST): + try: session = dg.session(env) - try: - observation = session.start() - except BaseException: - session.close() - raise - except BaseException: - _session_slots.release() - raise - session_id = uuid.uuid4().hex + except BaseException: + _session_slots.release() + raise + session_id = uuid.uuid4().hex + try: + observation = session.start() + except BaseException: + # Its sandbox exists, so it is closed like a deleted session: a failed delete + # keeps the slot for the sweep to retry, and one given up on counts as leaked. + with _store_lock: + _closing[session_id] = session + _close_logged(session_id) + raise with _store_lock: _sessions[session_id] = session _last_used[session_id] = time.monotonic() diff --git a/deepgym/src/deepgym/async_core.py b/deepgym/src/deepgym/async_core.py index 0528a634..54f1f872 100644 --- a/deepgym/src/deepgym/async_core.py +++ b/deepgym/src/deepgym/async_core.py @@ -34,6 +34,7 @@ _validate_env_vars, build_run_result, error_result, + local_run_result, parse_wrapped_output, wrap_stderr, ) @@ -341,13 +342,7 @@ async def _run_local(self, env: Environment, model_output: str) -> RunResult: assert self._local_executor is not None start = time.perf_counter() exec_result = await self._local_executor.run(JudgeJob.for_environment(env, model_output)) - return build_run_result( - exec_result.verifier_result, - (time.perf_counter() - start) * 1000.0, - 'local', - stderr=exec_result.stderr, - exit_code=exec_result.exit_code, - ) + return local_run_result(exec_result, (time.perf_counter() - start) * 1000.0) def _sync_client(self) -> DeepGym: """A sync client on the same backend, for runners that are sync-only.""" @@ -401,7 +396,7 @@ async def _run_judge(self, sandbox: Sandbox, job: JudgeJob) -> ExecutionResult: try: setup = await sandbox.process.exec(f'bash -c {shlex.quote(SANDBOX_SETUP)}', timeout=60) if setup.exit_code != 0: - raise SandboxError(f'Judge setup failed: {setup.result[-2000:]}') + raise SandboxError(f'Judge setup failed: {setup.result}') for content, path in sandbox_uploads(job): await sandbox.fs.upload_file(content, path) except SandboxError: diff --git a/deepgym/src/deepgym/benchmark_ops.py b/deepgym/src/deepgym/benchmark_ops.py index 0089af17..172d28df 100644 --- a/deepgym/src/deepgym/benchmark_ops.py +++ b/deepgym/src/deepgym/benchmark_ops.py @@ -9,8 +9,8 @@ from pydantic import BaseModel, Field +from deepgym.adversarial import fingerprint_verifier from deepgym.models import Environment -from deepgym.reward_qa import fingerprint_verifier BenchmarkSplit = Literal['public_train', 'public_eval', 'private_holdout', 'canary'] diff --git a/deepgym/src/deepgym/cli_runtime.py b/deepgym/src/deepgym/cli_runtime.py index 5d9e35ff..fa0afab7 100644 --- a/deepgym/src/deepgym/cli_runtime.py +++ b/deepgym/src/deepgym/cli_runtime.py @@ -214,7 +214,7 @@ def execute_cli( def _checked(backend: SandboxBackend, command: str) -> str: result = backend.exec_trusted(command, 30) if result.exit_code: - raise SandboxError(f'CLI supervisor operation failed: {result.output[-300:]}') + raise SandboxError(f'CLI supervisor operation failed: {result.output}') return result.output diff --git a/deepgym/src/deepgym/console/db.py b/deepgym/src/deepgym/console/db.py index d7247520..559b0c46 100644 --- a/deepgym/src/deepgym/console/db.py +++ b/deepgym/src/deepgym/console/db.py @@ -8,6 +8,8 @@ from datetime import datetime, timezone from pathlib import Path +from deepgym.models import RunResult + DEFAULT_DB_PATH = Path.home() / '.deepgym' / 'console.db' _SCHEMA = """ @@ -40,7 +42,9 @@ frames TEXT NOT NULL, result TEXT, created_at TEXT NOT NULL, - trace TEXT + trace TEXT, + has_verdict INTEGER, + error_type TEXT ); CREATE INDEX IF NOT EXISTS episodes_created_at ON episodes (created_at); CREATE TABLE IF NOT EXISTS screenshots ( @@ -58,11 +62,47 @@ def init_db(path: Path) -> None: with connect(path) as conn: conn.execute('PRAGMA journal_mode=WAL') conn.executescript(_SCHEMA) - # Databases from before traces were stored lack the column. Holding the write lock - # makes check-then-add atomic when two processes open an old database at once. + # Older databases lack the newer episode columns. Holding the write lock makes + # check-then-add atomic when two processes open an old database at once. conn.execute('BEGIN IMMEDIATE') - if 'trace' not in {row['name'] for row in conn.execute('PRAGMA table_info(episodes)')}: + columns = {row['name'] for row in conn.execute('PRAGMA table_info(episodes)')} + if 'trace' not in columns: conn.execute('ALTER TABLE episodes ADD COLUMN trace TEXT') + if 'has_verdict' not in columns: + _add_verdict_columns(conn) + + +def _add_verdict_columns(conn: sqlite3.Connection) -> None: + """Add the episodes' has_verdict and error_type, filled in from each stored result. + + Episodes stored before them kept a result without a verdict as a 0 score; that + score and pass are cleared, as :func:`verdict_columns` stores them now. + """ + conn.execute('ALTER TABLE episodes ADD COLUMN has_verdict INTEGER') + conn.execute('ALTER TABLE episodes ADD COLUMN error_type TEXT') + rows = conn.execute('SELECT id, result FROM episodes WHERE result IS NOT NULL').fetchall() + results = [(row['id'], RunResult.model_validate_json(row['result'])) for row in rows] + conn.executemany( + 'UPDATE episodes SET score = ?, passed = ?, error_type = ?, has_verdict = ? WHERE id = ?', + [ + (*verdict_columns(result), result.has_verdict, episode_id) + for episode_id, result in results + ], + ) + + +def verdict_columns(result: RunResult) -> tuple[float | None, bool | None, str | None]: + """Return the score, passed and error_type to store for *result*. + + A result without a verdict (``RunResult.has_verdict`` False) keeps no score or pass, + and always has an error type: its own, else ``'verifier_error'``. + """ + judged = result.has_verdict + return ( + result.score if judged else None, + result.passed if judged else None, + result.error_type or (None if judged else 'verifier_error'), + ) @contextmanager diff --git a/deepgym/src/deepgym/console/episode_store.py b/deepgym/src/deepgym/console/episode_store.py index c0174244..e94145f7 100644 --- a/deepgym/src/deepgym/console/episode_store.py +++ b/deepgym/src/deepgym/console/episode_store.py @@ -11,12 +11,20 @@ from pathlib import Path from uuid import uuid4 -from deepgym.console.db import DEFAULT_DB_PATH, connect, init_db, utc_now -from deepgym.console.schemas import EpisodeCreate, EpisodeDetail, EpisodeSummary, Frame +from deepgym.console.db import DEFAULT_DB_PATH, connect, init_db, utc_now, verdict_columns +from deepgym.console.schemas import ( + EpisodeCreate, + EpisodeDetail, + EpisodePage, + EpisodeSummary, + Frame, +) from deepgym.models import RunResult, Trajectory from deepgym.traces import Trace -_SUMMARY_COLUMNS = 'id, environment, steps, total_reward, score, passed, created_at' +_SUMMARY_COLUMNS = ( + 'id, environment, steps, total_reward, score, passed, has_verdict, error_type, created_at' +) class EpisodeStore: @@ -36,21 +44,27 @@ def add(self, episode: EpisodeCreate, trace: Trace | None = None) -> EpisodeSumm """ frames, screenshots = split_frames(episode.trajectory) result = episode.result + score, passed, error_type = ( + (None, None, None) if result is None else verdict_columns(result) + ) summary = EpisodeSummary( id=uuid4().hex[:12], environment=episode.environment, steps=len(episode.trajectory.steps), total_reward=episode.trajectory.total_reward, - score=None if result is None else result.score, - passed=None if result is None else result.passed, + score=score, + passed=passed, + has_verdict=None if result is None else result.has_verdict, + error_type=error_type, created_at=utc_now(), ) with connect(self._path) as conn: conn.execute( 'INSERT INTO episodes (id, environment, steps, total_reward, score, passed, ' - 'frames, result, created_at, trace) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + 'has_verdict, error_type, frames, result, created_at, trace) ' + 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', (summary.id, summary.environment, summary.steps, summary.total_reward, - summary.score, summary.passed, + summary.score, summary.passed, summary.has_verdict, summary.error_type, json.dumps([frame.model_dump(mode='json') for frame in frames]), None if result is None else result.model_dump_json(), summary.created_at, None if trace is None else trace.model_dump_json()), @@ -61,16 +75,27 @@ def add(self, episode: EpisodeCreate, trace: Trace | None = None) -> EpisodeSumm ) return summary - def list(self, environment: str | None = None, limit: int = 200) -> list[EpisodeSummary]: - """Return the newest episodes, optionally only those of *environment*.""" + def page(self, environment: str | None, offset: int, limit: int | None) -> EpisodePage: + """Return one page of the episodes, newest first, and how many there are in all. + + Args: + environment: Only this environment's episodes, if given. + offset: How many of the newest episodes to skip. + limit: Most episodes to return; None returns every one after *offset*. + + Returns: + The page, with the total count of matching episodes. + """ where, params = ('WHERE environment = ?', (environment,)) if environment else ('', ()) with connect(self._path) as conn: + total = conn.execute(f'SELECT COUNT(*) FROM episodes {where}', params).fetchone()[0] rows = conn.execute( f'SELECT {_SUMMARY_COLUMNS} FROM episodes {where} ' - 'ORDER BY created_at DESC, rowid DESC LIMIT ?', - (*params, limit), + 'ORDER BY created_at DESC, rowid DESC LIMIT ? OFFSET ?', + (*params, -1 if limit is None else limit, offset), # SQLite: LIMIT -1 is none ).fetchall() - return [EpisodeSummary.model_validate(dict(row)) for row in rows] + episodes = [EpisodeSummary.model_validate(dict(row)) for row in rows] + return EpisodePage(episodes=episodes, total=total) def traces(self) -> tuple[list[Trace], int]: """Return the stored traces, and how many episodes were recorded without one.""" diff --git a/deepgym/src/deepgym/console/episodes.py b/deepgym/src/deepgym/console/episodes.py index 5ad5c21e..5ec31eaf 100644 --- a/deepgym/src/deepgym/console/episodes.py +++ b/deepgym/src/deepgym/console/episodes.py @@ -4,10 +4,10 @@ import binascii -from fastapi import APIRouter, HTTPException, Response, status +from fastapi import APIRouter, HTTPException, Query, Response, status from deepgym.console.deps import EpisodeStoreDep -from deepgym.console.schemas import EpisodeCreate, EpisodeDetail, EpisodeSummary +from deepgym.console.schemas import EpisodeCreate, EpisodeDetail, EpisodePage, EpisodeSummary router = APIRouter(prefix='/episodes', tags=['episodes']) @@ -24,12 +24,15 @@ def create_episode(body: EpisodeCreate, episodes: EpisodeStoreDep) -> EpisodeSum ) from exc -@router.get('', response_model=list[EpisodeSummary]) +@router.get('', response_model=EpisodePage) def list_episodes( - episodes: EpisodeStoreDep, environment: str | None = None -) -> list[EpisodeSummary]: - """List the newest episodes, optionally for one environment.""" - return episodes.list(environment) + episodes: EpisodeStoreDep, + environment: str | None = None, + offset: int = Query(0, ge=0), + limit: int | None = Query(None, ge=1), +) -> EpisodePage: + """List the newest episodes a page at a time (every one without *limit*), with the total.""" + return episodes.page(environment, offset, limit) @router.get('/{episode_id}', response_model=EpisodeDetail) diff --git a/deepgym/src/deepgym/console/run_store.py b/deepgym/src/deepgym/console/run_store.py index 38fcf6dd..4169be5d 100644 --- a/deepgym/src/deepgym/console/run_store.py +++ b/deepgym/src/deepgym/console/run_store.py @@ -9,12 +9,13 @@ from pathlib import Path from uuid import uuid4 -from deepgym.console.db import connect, init_db, utc_now +from deepgym.console.db import connect, init_db, utc_now, verdict_columns from deepgym.console.schemas import ( EnvironmentStats, ErrorTypeCount, RunDetail, RunMode, + RunPage, RunSpec, RunStats, RunSummary, @@ -26,7 +27,7 @@ 'id, group_id, environment, mode, provider, reference, status, score, passed, ' 'error_type, duration_ms, created_at, finished_at' ) -# Columns list() and stats() may filter on; keys are code-defined, values are bound parameters. +# Columns page() and stats() may filter on; keys are code-defined, values are bound parameters. _FILTER_COLUMNS = frozenset({'status', 'environment', 'provider', 'group_id'}) _FINISHED = "status IN ('completed', 'failed')" # Select list of RunTotals; SUM over no rows is NULL, hence the COALESCEs. A finished run @@ -100,17 +101,15 @@ def complete(self, run_id: str, result: RunResult, duration_ms: float) -> None: """Record the verifier result of a run that finished. A result without a verdict (``RunResult.has_verdict`` False) keeps no score or - pass: the mean score counts it as 0, the judged totals leave it out, and its error - type counts it as an error. + pass (see :func:`verdict_columns`): the mean score counts it as 0, the judged + totals leave it out, and its error type counts it as an error. """ - judged = result.has_verdict with connect(self._path) as conn: conn.execute( "UPDATE runs SET status = 'completed', score = ?, passed = ?, error_type = ?, " 'result = ?, duration_ms = ?, finished_at = ? WHERE id = ?', - (result.score if judged else None, result.passed if judged else None, - result.error_type or (None if judged else 'verifier_error'), - result.model_dump_json(), duration_ms, utc_now(), run_id), + (*verdict_columns(result), result.model_dump_json(), duration_ms, utc_now(), + run_id), ) # fmt: skip def fail(self, run_id: str, error_type: str, error: str, duration_ms: float) -> None: @@ -138,30 +137,33 @@ def fail_unfinished(self) -> int: ) return cursor.rowcount - def list(self, filters: dict[str, str | None], limit: int = 200) -> list[RunSummary]: - """Return the newest runs matching every non-None filter. + def page(self, filters: dict[str, str | None], offset: int, limit: int | None) -> RunPage: + """Return one page of the runs matching every non-None filter, newest first. Args: filters: Column -> value, for status, environment, provider or group_id. - limit: Maximum number of runs to return. + offset: How many of the newest matching runs to skip. + limit: Most runs to return; None returns every one after *offset*. Returns: - Runs, newest first. + The page, with the total count of matching runs. """ where, params = _where(filters) with connect(self._path) as conn: + total = conn.execute(f'SELECT COUNT(*) FROM runs WHERE {where}', params).fetchone()[0] rows = conn.execute( f'SELECT {_SUMMARY_COLUMNS} FROM runs WHERE {where} ' - 'ORDER BY created_at DESC, rowid DESC LIMIT ?', - (*params, limit), + 'ORDER BY created_at DESC, rowid DESC LIMIT ? OFFSET ?', + (*params, -1 if limit is None else limit, offset), # SQLite: LIMIT -1 is none ).fetchall() - return [RunSummary.model_validate(dict(row)) for row in rows] + runs = [RunSummary.model_validate(dict(row)) for row in rows] + return RunPage(runs=runs, total=total) def stats(self, filters: dict[str, str | None], hours: int | None) -> RunStats: """Aggregate the runs matching every non-None filter, started in the last *hours*. Args: - filters: Column -> value, as for :meth:`list`. + filters: Column -> value, as for :meth:`page`. hours: Window length; the window just before it is totalled too, for deltas. None aggregates every run and has no previous window. diff --git a/deepgym/src/deepgym/console/runs.py b/deepgym/src/deepgym/console/runs.py index ddb19a5d..7fc8899d 100644 --- a/deepgym/src/deepgym/console/runs.py +++ b/deepgym/src/deepgym/console/runs.py @@ -12,6 +12,7 @@ RunConfig, RunCreate, RunDetail, + RunPage, RunSpec, RunStats, RunStatus, @@ -85,12 +86,15 @@ def run_filters( RunFiltersDep = Annotated[dict[str, str | None], Depends(run_filters)] -@router.get('', response_model=list[RunSummary]) +@router.get('', response_model=RunPage) def list_runs( - runs: RunStoreDep, filters: RunFiltersDep, limit: int = Query(200, ge=1, le=1000) -) -> list[RunSummary]: - """List the newest runs matching every given filter.""" - return runs.list(filters, limit) + runs: RunStoreDep, + filters: RunFiltersDep, + offset: int = Query(0, ge=0), + limit: int | None = Query(None, ge=1), +) -> RunPage: + """List the newest matching runs a page at a time (every one without *limit*) and the total.""" + return runs.page(filters, offset, limit) @router.get('/stats', response_model=RunStats) diff --git a/deepgym/src/deepgym/console/schemas.py b/deepgym/src/deepgym/console/schemas.py index 8c8e2762..a427fbab 100644 --- a/deepgym/src/deepgym/console/schemas.py +++ b/deepgym/src/deepgym/console/schemas.py @@ -89,6 +89,14 @@ class RunSummary(BaseModel): finished_at: str | None = None +class RunPage(BaseModel): + """One page of the runs table, newest first.""" + + runs: list[RunSummary] + total: int + """Matching runs across every page.""" + + class RunDetail(RunSummary): """A run with its solution, error and full verifier result.""" @@ -172,10 +180,27 @@ class EpisodeSummary(BaseModel): steps: int total_reward: float score: float | None = None + """None when the episode was recorded without a result, or its result has no verdict.""" + passed: bool | None = None + has_verdict: bool | None = None + """Whether the verifier judged the episode (``RunResult.has_verdict``); None when it was + recorded without a result.""" + + error_type: str | None = None + """The result's error type; always set when it has no verdict.""" + created_at: str +class EpisodePage(BaseModel): + """One page of the episodes table, newest first.""" + + episodes: list[EpisodeSummary] + total: int + """Matching episodes across every page.""" + + class Frame(BaseModel): """One replay frame: an observation and the action taken from it (none for the last).""" diff --git a/deepgym/src/deepgym/core.py b/deepgym/src/deepgym/core.py index 566e0ce4..96c6bb42 100644 --- a/deepgym/src/deepgym/core.py +++ b/deepgym/src/deepgym/core.py @@ -25,7 +25,13 @@ Trajectory, VerdictTotals, ) -from deepgym.sandbox import LocalExecutor, build_run_result, error_result, run_judge_in_sandbox +from deepgym.sandbox import ( + LocalExecutor, + build_run_result, + error_result, + local_run_result, + run_judge_in_sandbox, +) from deepgym.sandboxes import PROVIDERS, Provider, Sandbox, check_provider, open_sandbox from deepgym.sandboxes.daytona import DaytonaDesktop, DaytonaSandbox from deepgym.session import LocalBackend, SandboxBackend, Session, run_episode @@ -162,13 +168,7 @@ def _run_local(self, env: Environment, model_output: str) -> RunResult: assert self._local_executor is not None start = time.perf_counter() exec_result = self._local_executor.run(JudgeJob.for_environment(env, model_output)) - return build_run_result( - exec_result.verifier_result, - (time.perf_counter() - start) * 1000, - 'local', - stderr=exec_result.stderr, - exit_code=exec_result.exit_code, - ) + return local_run_result(exec_result, (time.perf_counter() - start) * 1000) def _run_in(self, sandbox: Sandbox, env: Environment, model_output: str) -> RunResult: """Execute in a fresh *sandbox*, deleted afterwards.""" diff --git a/deepgym/src/deepgym/cybergym_artifacts.py b/deepgym/src/deepgym/cybergym_artifacts.py index 903d0348..6b5a1bef 100644 --- a/deepgym/src/deepgym/cybergym_artifacts.py +++ b/deepgym/src/deepgym/cybergym_artifacts.py @@ -392,7 +392,7 @@ def file_header(chunk): elif line.startswith('+++ '): new_path = strip_prefix(line[4:].split('\t')[0]) elif line.startswith(('+', '-', ' ')): - raise ValueError(f'changed line outside a hunk: {line.strip()[:80]}') + raise ValueError(f'changed line outside a hunk: {line.strip()}') return old_path, new_path, len(chunk) diff --git a/deepgym/src/deepgym/exceptions.py b/deepgym/src/deepgym/exceptions.py index 5a185b8f..094bffcd 100644 --- a/deepgym/src/deepgym/exceptions.py +++ b/deepgym/src/deepgym/exceptions.py @@ -100,6 +100,10 @@ class TimeoutError(DeepGymError): """Raise when execution exceeds the configured timeout.""" +class SessionClosedError(DeepGymError): + """Raise when a session is used after close(): its sandbox may already be deleted.""" + + class ActionError(DeepGymError): """Raise when the environment rejects an agent's action (not allowed, unknown key).""" diff --git a/deepgym/src/deepgym/factory/check.py b/deepgym/src/deepgym/factory/check.py index 75f14700..71de3602 100644 --- a/deepgym/src/deepgym/factory/check.py +++ b/deepgym/src/deepgym/factory/check.py @@ -206,7 +206,7 @@ def protocol_check(results: Sequence[RunResult]) -> QACheck: """ broken = [result for result in results if result.error_type in _PROTOCOL_ERRORS] if broken: - details = f'verifier output breaks the protocol: {broken[0].output.strip()[:300]}' + details = f'verifier output breaks the protocol: {broken[0].output.strip()}' return QACheck(name='protocol', status='fail', details=details) if not results[0].cases: details = ( diff --git a/deepgym/src/deepgym/harbor/grading.py b/deepgym/src/deepgym/harbor/grading.py index 6c967e1d..de804013 100644 --- a/deepgym/src/deepgym/harbor/grading.py +++ b/deepgym/src/deepgym/harbor/grading.py @@ -37,8 +37,6 @@ TESTS_DIR = '/tests' REWARD_DIR = '/logs/verifier' _TESTS_ARCHIVE = f'{STAGING_DIR}/tests.tgz' -# Characters of test output kept in the run result. -_LOG_TAIL = 4000 # Image variables tests/test.sh may see, read before the restore with shell builtins. _PASSED_ON = ('PATH', 'LANG', 'LC_ALL', 'TERM') _IMAGE_ENV = "printf '%s\\0' " + ' '.join(f'"${{{name}-}}"' for name in _PASSED_ON) @@ -54,7 +52,7 @@ def grade(sandbox: Sandbox, task: HarborTask, workspace: bytes, workdir: str) -> workdir: Where to unpack it; tests/test.sh runs there. Returns: - The reward as a verifier result, the tail of the tests' output, and their exit code. + The reward as a verifier result, the tests' whole output, and their exit code. Raises: SandboxError: If setting up the sandbox fails. @@ -73,7 +71,7 @@ def grade(sandbox: Sandbox, task: HarborTask, workspace: bytes, workdir: str) -> timeout=120, ) if staged.exit_code != 0: - raise SandboxError(f'Failed to stage the tests: {staged.output[-500:]}') + raise SandboxError(f'Failed to stage the tests: {staged.output}') tests = backend.exec_root( f'{TESTS_DIR}/test.sh', math.ceil(task.verifier_timeout_sec), @@ -83,7 +81,7 @@ def grade(sandbox: Sandbox, task: HarborTask, workspace: bytes, workdir: str) -> listing = backend.exec_trusted(read_reward_command(REWARD_DIR), timeout=60) return ExecutionResult( reward_result(listing.output, tests), - stderr=tests.output[-_LOG_TAIL:], + stderr=tests.output, exit_code=tests.exit_code, ) finally: @@ -137,10 +135,10 @@ def reward_result(listing: str, tests: ExecResult) -> VerifierResult: ) else: raise VerifierError( - f'tests/test.sh wrote no reward (exit {tests.exit_code}): {tests.output[-500:]}' + f'tests/test.sh wrote no reward (exit {tests.exit_code}): {tests.output}' ) except (ValueError, TypeError, AttributeError) as exc: - raise VerifierError(f'Unreadable {name}: {body[:200]!r}') from exc + raise VerifierError(f'Unreadable {name}: {body!r}') from exc score = checked_score(score) return VerifierResult( score=score, diff --git a/deepgym/src/deepgym/harbor/loader.py b/deepgym/src/deepgym/harbor/loader.py index 1568bcbc..6f9527af 100644 --- a/deepgym/src/deepgym/harbor/loader.py +++ b/deepgym/src/deepgym/harbor/loader.py @@ -1,10 +1,12 @@ """Find Harbor tasks in a local folder, a git repository or a Hugging Face dataset. -Remote datasets are shallow-cloned once into ``~/.deepgym/benchmarks/harbor/`` and reused. +Remote datasets are shallow-cloned once into ``~/.deepgym/benchmarks/harbor/``, one folder +per URL, and reused only while git confirms the folder is a clone of that URL. """ from __future__ import annotations +import hashlib import re import subprocess import tempfile @@ -27,7 +29,8 @@ def dataset_root(source: str | Path) -> Path: Face dataset. Raises: - DeepGymError: If *source* is none of those, or cloning it fails. + DeepGymError: If *source* is none of those, cloning it fails, or its cache folder + isn't a clone of it. """ local = Path(source).expanduser() if local.is_dir(): @@ -37,9 +40,11 @@ def dataset_root(source: str | Path) -> Path: if not _HF_DATASET.fullmatch(url): raise DeepGymError(f'{url!r} is not a folder, a git URL or a Hugging Face dataset') url = f'https://huggingface.co/datasets/{url}' - target = _cache_dir() / re.sub(r'[^\w.-]+', '_', url.split('://')[-1]) + target = _cache_dir() / _cache_name(url) if not target.is_dir(): _clone(url, target) + if _cloned_from(target) != url: + raise DeepGymError(f'{target} is not a clone of {url}; delete it to clone again') return target @@ -75,6 +80,29 @@ def _cache_dir() -> Path: return Path.home() / '.deepgym' / 'benchmarks' / 'harbor' +def _cache_name(url: str) -> str: + """The cache folder name for *url*: readable, and keyed by a digest of the whole URL. + + The readable part alone isn't unique: ``host/team_one/task`` and ``host/team/one_task`` + both read ``host_team_one_task``. + """ + readable = re.sub(r'[^\w.-]+', '_', url.split('://')[-1]) + return f'{readable}-{hashlib.sha256(url.encode()).hexdigest()[:16]}' + + +def _cloned_from(clone: Path) -> str | None: + """The URL *clone* was cloned from, as git recorded it; None if it isn't a git clone.""" + try: + done = subprocess.run( + ['git', '--git-dir', clone / '.git', 'config', '--local', '--get', 'remote.origin.url'], + capture_output=True, + text=True, + ) + except OSError as exc: + raise DeepGymError(f'Could not read where {clone} was cloned from: {exc}') from exc + return done.stdout.strip() if done.returncode == 0 else None + + def _clone(url: str, target: Path) -> None: """Shallow-clone *url* to *target*, atomically: a failed clone leaves nothing behind. @@ -94,7 +122,7 @@ def _clone(url: str, target: Path) -> None: except (OSError, subprocess.TimeoutExpired) as exc: raise DeepGymError(f'Could not clone {url}: {exc}') from exc if done.returncode != 0: - raise DeepGymError(f'Could not clone {url}: {done.stderr.strip()[-500:]}') + raise DeepGymError(f'Could not clone {url}: {done.stderr.strip()}') try: clone.rename(target) except OSError as exc: diff --git a/deepgym/src/deepgym/integrations/lm_eval.py b/deepgym/src/deepgym/integrations/lm_eval.py index fc831cdb..ced98c15 100644 --- a/deepgym/src/deepgym/integrations/lm_eval.py +++ b/deepgym/src/deepgym/integrations/lm_eval.py @@ -9,7 +9,10 @@ from deepgym import load_environment from deepgym.integrations.lm_eval import make_lm_eval_task - tasks = [make_lm_eval_task(load_environment(name), name) for name in ('coin_change', 'two_sum')] + tasks = [ + make_lm_eval_task(load_environment(name), name, max_gen_toks=8192) + for name in ('coin_change', 'two_sum') + ] results = lm_eval.simple_evaluate( model='hf', model_args='pretrained=Qwen/Qwen2.5-0.5B-Instruct', tasks=tasks ) @@ -22,6 +25,10 @@ one document is the environment's ``task`` string, given to the model as the prompt. - The last fenced code block of the generation (or all of it) is run by the environment's verifier, as in the TRL and verl adapters. + - Generation runs to the model's own maximum output, which the caller passes as + ``max_gen_toks`` (lm-eval needs a number, and its backends would default to 256). + lm-eval hands ``process_results`` the text alone, so a generation that reached it + can't be told from one that ended, and is scored as written. - Metrics: ``deepgym_score`` (0.0-1.0) and ``deepgym_pass`` (0 or 1), averaged over the documents that got a verdict, and ``deepgym_verdict``, the fraction that did. A sandbox or verifier failure counts in neither score nor pass, instead of as a wrong @@ -49,7 +56,7 @@ def make_lm_eval_task( - env: Environment, task_name: str, dg: DeepGym | None = None + env: Environment, task_name: str, dg: DeepGym | None = None, *, max_gen_toks: int ) -> ConfigurableTask: """Create an lm-eval task for a DeepGym environment. @@ -60,6 +67,9 @@ def make_lm_eval_task( task_name: Short name used in lm-eval (e.g. ``'coin_change'``). The task is named ``deepgym_{task_name}``. dg: DeepGym client that runs the verifier (auto-created if None). + max_gen_toks: The model's maximum output, in tokens. lm-eval needs a number (its + backends would otherwise cut every answer at 256), and only the caller knows + the model's real maximum, so there is no default. Returns: A ConfigurableTask (lm-evaluation-harness 0.4.9+) with one document. @@ -101,14 +111,15 @@ def process_results(doc: dict, results: list[str]) -> dict[str, float]: 'doc_to_text': 'task', 'doc_to_target': '', 'output_type': 'generate_until', - # No stop strings (code has blank lines): generation ends at EOS or max_gen_toks. - 'generation_kwargs': {'until': [], 'max_gen_toks': 1024, 'do_sample': False}, + # No stop strings (code has blank lines): generation ends at EOS or the model's + # own maximum, which the caller states. + 'generation_kwargs': {'until': [], 'max_gen_toks': max_gen_toks, 'do_sample': False}, 'process_results': process_results, 'metric_list': [ {'metric': 'deepgym_score', 'aggregation': 'nanmean', 'higher_is_better': True}, {'metric': 'deepgym_pass', 'aggregation': 'nanmean', 'higher_is_better': True}, {'metric': 'deepgym_verdict', 'aggregation': 'mean', 'higher_is_better': True}, ], - 'metadata': {'version': 2}, + 'metadata': {'version': 3}, } ) diff --git a/deepgym/src/deepgym/integrations/zai.py b/deepgym/src/deepgym/integrations/zai.py index 9b0d2bd1..b19c447b 100644 --- a/deepgym/src/deepgym/integrations/zai.py +++ b/deepgym/src/deepgym/integrations/zai.py @@ -9,13 +9,40 @@ from __future__ import annotations import os -from collections.abc import Sequence -from typing import Any +from collections.abc import Callable, Sequence +from pathlib import Path +from typing import Any, Literal import httpx +from pydantic import BaseModel, ConfigDict _DEFAULT_BASE_URL = 'https://api.z.ai/api/paas/v4' _DEFAULT_MODEL = 'glm-5.1' +# Reaching the endpoint, not generating: a reply itself has no time limit by default. +_CONNECT_TIMEOUT_S = 30.0 + + +class Completion(BaseModel): + """The assistant's reply to one chat-completions request, and why generation stopped.""" + + model_config = ConfigDict(frozen=True) + + text: str + """The reply's content; empty if the endpoint returned none.""" + finish_reason: str | None + """The endpoint's ``finish_reason``: ``'stop'`` at a natural end, ``'length'`` when an + output limit cut the reply off, ``'content_filter'`` when the endpoint withheld some of + it. None if the endpoint gave none.""" + + @property + def whole(self) -> bool | None: + """Whether this is the model's whole answer: True only for a natural end + (``'stop'``), False when a limit or filter cut it (``'length'``, + ``'content_filter'``), None when unknown (no reason, or one this client doesn't + know). Only a whole answer may be scored as the model's.""" + if self.finish_reason == 'stop': + return True + return False if self.finish_reason in ('length', 'content_filter') else None class ZaiChatClient: @@ -27,15 +54,24 @@ def __init__( *, base_url: str = _DEFAULT_BASE_URL, model: str = _DEFAULT_MODEL, - timeout: float = 120.0, + timeout: float | None = None, transport: httpx.BaseTransport | None = None, ) -> None: + """Args: + api_key: The endpoint's API key. + base_url: The OpenAI-compatible endpoint. + model: The model to ask. + timeout: Seconds to wait for a reply; None (the default) waits as long as the + model generates, since a full-length reply can take many minutes. Only + connecting is bounded then (:data:`_CONNECT_TIMEOUT_S`). + transport: An httpx transport, for tests. + """ if not api_key: raise ValueError('api_key must be non-empty') self._api_key = api_key self.base_url = base_url.rstrip('/') self.model = model - self.timeout = timeout + self.timeout = httpx.Timeout(timeout, connect=_CONNECT_TIMEOUT_S) self._transport = transport @classmethod @@ -61,21 +97,24 @@ def complete( self, messages: Sequence[dict[str, str]], *, - max_tokens: int = 4096, + max_tokens: int | None = None, temperature: float = 0.2, thinking: str | None = 'enabled', - ) -> str: - """Return the assistant message content for a chat-completions request. + ) -> Completion: + """Return the assistant's reply to a chat-completions request, with its finish reason. + No output limit is sent unless *max_tokens* is given, so the model may answer up to + its own maximum; check ``Completion.whole`` before scoring the reply as the model's. Pass ``thinking=None`` to omit the Z.ai-specific field for other OpenAI-compatible endpoints. """ payload: dict[str, Any] = { 'model': self.model, 'messages': list(messages), - 'max_tokens': max_tokens, 'temperature': temperature, } + if max_tokens is not None: + payload['max_tokens'] = max_tokens if thinking is not None: payload['thinking'] = {'type': thinking} headers = { @@ -90,17 +129,17 @@ def complete( ) response.raise_for_status() data = response.json() - return _extract_message_content(data) + return _completion(data) def complete_prompt( self, prompt: str, *, system: str | None = None, - max_tokens: int = 4096, + max_tokens: int | None = None, temperature: float = 0.2, thinking: str | None = 'enabled', - ) -> str: + ) -> Completion: """Convenience wrapper for a single user prompt.""" messages: list[dict[str, str]] = [] if system: @@ -114,12 +153,39 @@ def complete_prompt( ) -def _extract_message_content(data: dict[str, Any]) -> str: - """Extract assistant content from an OpenAI-compatible response payload.""" +def _completion(data: dict[str, Any]) -> Completion: + """Read the assistant's reply and finish reason from an OpenAI-compatible response.""" try: - content = data['choices'][0]['message']['content'] + choice = data['choices'][0] + content = choice['message']['content'] except (KeyError, IndexError, TypeError) as exc: raise ValueError(f'Unexpected Z.ai response shape: {data!r}') from exc - if content is None: - return '' - return str(content) + text = '' if content is None else str(content) + return Completion(text=text, finish_reason=choice.get('finish_reason')) + + +def cached_completion( + cache_path: Path, complete: Callable[[], Completion], *, refresh: bool = False +) -> tuple[Completion, Literal['cache', 'model']]: + """Return a completion from the cache at *cache_path*, else *complete*'s, cached with its + finish reason in ``.completion.json``. + + A legacy cache (the answer text alone, at *cache_path* itself) is read as opaque text, + never parsed, so an answer that looks like a cache record can't pass for one; it comes + back with ``finish_reason`` None: unknown, not known to be whole. + + Returns: + The completion, and where it came from. + """ + record = cache_path.with_name(cache_path.name + '.completion.json') + if not refresh: + if record.exists(): + return Completion.model_validate_json(record.read_text(encoding='utf-8')), 'cache' + if cache_path.exists(): + return Completion( + text=cache_path.read_text(encoding='utf-8'), finish_reason=None + ), 'cache' + completion = complete() + record.parent.mkdir(parents=True, exist_ok=True) + record.write_text(completion.model_dump_json(), encoding='utf-8') + return completion, 'model' diff --git a/deepgym/src/deepgym/judge.py b/deepgym/src/deepgym/judge.py index f899de5e..2b7692a5 100644 --- a/deepgym/src/deepgym/judge.py +++ b/deepgym/src/deepgym/judge.py @@ -311,6 +311,17 @@ def sandbox_python() -> str: return os.path.realpath(executable) +@functools.cache +def _warn_unsandboxed() -> None: + """Say once per process that local solutions run unsandboxed, so a stray + :data:`ALLOW_UNSANDBOXED_ENV` never goes unnoticed (results also record it).""" + logger.warning( + '%s is set and no sandbox works: local solutions run unsandboxed and can read ' + 'every file you can, verifiers included', + ALLOW_UNSANDBOXED_ENV, + ) + + def local_isolation(expose: list[str]) -> dict: """Isolation spec for a local run: what its solution may read. @@ -325,6 +336,7 @@ def local_isolation(expose: list[str]) -> dict: kind = local_isolation_kind() if kind == 'none': if unsandboxed_allowed(): + _warn_unsandboxed() return {'kind': 'none'} raise SandboxError( 'No working sandbox-exec or bwrap on this machine, so a local solution could ' diff --git a/deepgym/src/deepgym/models.py b/deepgym/src/deepgym/models.py index cd900ea3..765a1b6f 100644 --- a/deepgym/src/deepgym/models.py +++ b/deepgym/src/deepgym/models.py @@ -370,6 +370,12 @@ def check_components(cls, value: dict[str, float] | None) -> dict[str, float] | cases: list[CaseResult] | None = None """Per-test-case breakdown passed through from the verifier.""" + isolation: Literal['macos', 'bwrap', 'none'] | None = None + """How a local run isolated the solution: ``sandbox-exec``, ``bwrap``, or ``none`` when + ``DEEPGYM_ALLOW_UNSANDBOXED`` let it run unsandboxed. A local session's agent actions + always run unsandboxed (``none``). None for a run in a sandbox provider, where the + solution and the agent always run as a separate user.""" + @property def has_verdict(self) -> bool: """False when the verifier or sandbox failed instead of judging the solution. diff --git a/deepgym/src/deepgym/qa/checks.py b/deepgym/src/deepgym/qa/checks.py index 5969923c..0517f91b 100644 --- a/deepgym/src/deepgym/qa/checks.py +++ b/deepgym/src/deepgym/qa/checks.py @@ -55,9 +55,9 @@ def stub_solution(reference: str) -> str: def _summary(result: RunResult) -> str: - """Score plus the first line of the verifier's details, for a check's details.""" - first_line = (result.output or result.stderr).strip().split('\n', 1)[0][:120] - return f'score {result.score:.2f}' + (f' ({first_line})' if first_line else '') + """Score plus the verifier's details, whole, for a check's details.""" + details = (result.output or result.stderr).strip() + return f'score {result.score:.2f}' + (f' ({details})' if details else '') def earns_nothing(name: str, result: RunResult) -> QACheck: @@ -216,7 +216,7 @@ def hack_resistant(env: Environment, dg: DeepGym, strategies: list[str] | None = ) return QACheck(name='hack_resistant', status='skip', details=details) if not_run: - reasons = ', '.join(f'{r.strategy} ({r.details[:120]})' for r in not_run) + reasons = ', '.join(f'{r.strategy} ({r.details})' for r in not_run) details = f'{len(not_run)} of {report.attacks_run} attacks got no verdict: {reasons}' return QACheck(name='hack_resistant', status='inconclusive', details=details) top = max(result.score for result in report.results) diff --git a/deepgym/src/deepgym/qa/difficulty.py b/deepgym/src/deepgym/qa/difficulty.py index 7a0dfaa4..e15ff6b7 100644 --- a/deepgym/src/deepgym/qa/difficulty.py +++ b/deepgym/src/deepgym/qa/difficulty.py @@ -15,7 +15,7 @@ from deepgym.exceptions import DeepGymError from deepgym.integrations.verl import extract_code -from deepgym.integrations.zai import ZaiChatClient +from deepgym.integrations.zai import Completion, ZaiChatClient from deepgym.models import SCORE_TOLERANCE, Environment from deepgym.qa.checks import solved from deepgym.qa.report import DIFFICULTY_CHECK, QACheck @@ -63,10 +63,12 @@ def difficulty_check(env: Environment, dg: DeepGym, client: ZaiChatClient, sampl neither too hard nor flat. The last fenced code block of each completion is the solution; a completion with - none is an empty answer, the model's own failure. The endpoint's failures are not - the model's: a request it rate-limits (429), times out or fails (5xx) is retried - with backoff, and if it never comes back, or the verifier can't judge a sample, - the check is inconclusive. + none is an empty answer, the model's own failure. The probe sets no output limit, so + a completion the endpoint still cuts off at its own (finish_reason 'length') is no + answer at all, and the check is inconclusive rather than counting it as a failure. + The endpoint's failures are not the model's either: a request it rate-limits (429), + times out or fails (5xx) is retried with backoff, and if it never comes back, or the + verifier can't judge a sample, the check is inconclusive. Raises: DeepGymError: If the endpoint refuses a request outright (a bad key or model). @@ -79,7 +81,14 @@ def difficulty_check(env: Environment, dg: DeepGym, client: ZaiChatClient, sampl f'after {_RETRIES} retries: {missing[0]}' ) return QACheck(name=DIFFICULTY_CHECK, status='inconclusive', details=details) - results = dg.run_batch(env, [extract_code(str(text)) for text in completions]).results + answers = [completion for completion in completions if isinstance(completion, Completion)] + if partial := [answer.finish_reason for answer in answers if answer.whole is not True]: + details = ( + f'{len(partial)}/{samples} samples are not known to be whole answers (finish ' + f'reasons {sorted(set(map(str, partial)))}: cut off, filtered or unknown)' + ) + return QACheck(name=DIFFICULTY_CHECK, status='inconclusive', details=details) + results = dg.run_batch(env, [extract_code(answer.text) for answer in answers]).results if errored := sum(not result.has_verdict for result in results): details = f'{errored}/{samples} samples got no verdict (the verifier errored)' return QACheck(name=DIFFICULTY_CHECK, status='inconclusive', details=details) @@ -104,7 +113,7 @@ def difficulty_check(env: Environment, dg: DeepGym, client: ZaiChatClient, sampl return QACheck(name=DIFFICULTY_CHECK, status='pass', details=details) -def _complete(client: ZaiChatClient, prompt: str) -> str | Exception: +def _complete(client: ZaiChatClient, prompt: str) -> Completion | Exception: """Return one completion, retrying the endpoint's own failures with backoff. Waits as long as a Retry-After header asks, else a random time up to 2s, 4s, 8s ... diff --git a/deepgym/src/deepgym/qa/mutants.py b/deepgym/src/deepgym/qa/mutants.py index 3d17cdfa..a4caa303 100644 --- a/deepgym/src/deepgym/qa/mutants.py +++ b/deepgym/src/deepgym/qa/mutants.py @@ -100,8 +100,7 @@ class Mutant(BaseModel): def _short(node: ast.AST) -> str: - text = ast.unparse(node).split('\n', 1)[0] - return text if len(text) <= 60 else text[:57] + '...' + return ast.unparse(node) def _kind(node: ast.expr | None) -> str | None: @@ -311,7 +310,7 @@ def mutation_check(env: Environment, reference: str, dg: DeepGym) -> QACheck: if not (control.has_verdict and solved(control)): details = ( f'the reference re-printed without comments or formatting scores {control.score:.2f} ' - f'in a confined run ({control.output.strip()[:120]}); the verifier depends on ' + f'in a confined run ({control.output.strip()}); the verifier depends on ' 'formatting, or on something the run cannot reach (writes outside its temp ' 'directory, the network, new processes on macOS), so mutants of it cannot be ' 'judged' diff --git a/deepgym/src/deepgym/reward_qa.py b/deepgym/src/deepgym/reward_qa.py index b04cae59..e3cd8ca9 100644 --- a/deepgym/src/deepgym/reward_qa.py +++ b/deepgym/src/deepgym/reward_qa.py @@ -3,14 +3,18 @@ from __future__ import annotations import contextlib -import hashlib from collections.abc import Iterator from pathlib import Path from typing import Literal from pydantic import BaseModel, Field -from deepgym.adversarial import AdversarialReport, AdversarialTester +from deepgym.adversarial import ( + AdversarialReport, + AdversarialTester, + environment_id, + grading_fingerprint, +) from deepgym.exploit_db import ExploitDB, ExploitRecord from deepgym.models import Environment @@ -40,11 +44,6 @@ class VerifierAuditReport(BaseModel): stored: bool = False -def fingerprint_verifier(verifier_code: str) -> str: - """Return a stable short hash for a verifier.""" - return hashlib.sha256(verifier_code.encode('utf-8')).hexdigest()[:16] - - def classify_patterns( verifier_code: str, report: AdversarialReport | None = None, @@ -211,8 +210,8 @@ def audit( ) -> VerifierAuditReport: """Run a verifier audit and return a structured QA report.""" verifier_source = env.verifier_source - resolved_verifier_id = verifier_id or env.task[:80] - verifier_hash = fingerprint_verifier(verifier_source) + resolved_verifier_id = verifier_id or environment_id(env) + verifier_hash = grading_fingerprint(env) if persist: # Before the retest, as the scanners do: a stored result for a changed verifier # is dropped, or an inconclusive retest (never saved) would leave it standing. diff --git a/deepgym/src/deepgym/rl_exploit_discovery.py b/deepgym/src/deepgym/rl_exploit_discovery.py index b1453d5d..9fd50680 100644 --- a/deepgym/src/deepgym/rl_exploit_discovery.py +++ b/deepgym/src/deepgym/rl_exploit_discovery.py @@ -22,6 +22,7 @@ from pydantic import BaseModel, ConfigDict, Field +from deepgym.adversarial import claude_reply, environment_id from deepgym.exceptions import DeepGymError if TYPE_CHECKING: @@ -214,7 +215,7 @@ def discover(self, env: Environment, verifier_id: str = '') -> DiscoveryReport: Returns: DiscoveryReport with full round-by-round analysis. """ - vid = verifier_id or env.task[:80] + vid = verifier_id or environment_id(env) rounds: list[DiscoveryRound] = [] top_k_codes: list[str] = [] top_k_scores: list[float] = [] @@ -245,7 +246,7 @@ def discover(self, env: Environment, verifier_id: str = '') -> DiscoveryReport: score_progression.append(best_score) suspected += [ SuspectedExploit( - round_num=round_num, score=c['score'], code=c['code'][:500], reasons=reasons + round_num=round_num, score=c['score'], code=c['code'], reasons=reasons ) for c in judged if c['score'] >= EXPLOIT_SCORE and (reasons := shortcut_signs(c['code'], env)) @@ -259,8 +260,7 @@ def discover(self, env: Environment, verifier_id: str = '') -> DiscoveryReport: dr = DiscoveryRound( round_num=round_num, candidates=[ - {'code': c['code'][:500], 'score': c['score'], 'judged': c['judged']} - for c in scored + {'code': c['code'], 'score': c['score'], 'judged': c['judged']} for c in scored ], best_score=best_score, avg_score=round(avg_score, 4), @@ -348,16 +348,7 @@ def _call_llm_and_parse(self, prompt: str) -> list[str]: 'anthropic package required. Install with: pip install anthropic' ) from exc - try: - client = Anthropic(api_key=self._api_key) - message = client.messages.create( - model=self._model, - max_tokens=8192, - messages=[{'role': 'user', 'content': prompt}], - ) - text = message.content[0].text - except Exception as exc: - raise DeepGymError(f'Anthropic API call failed: {type(exc).__name__}: {exc}') from exc + text = claude_reply(Anthropic(api_key=self._api_key), self._model, prompt) return _parse_solutions(text) diff --git a/deepgym/src/deepgym/sandbox.py b/deepgym/src/deepgym/sandbox.py index 6997b388..f1ccb6be 100644 --- a/deepgym/src/deepgym/sandbox.py +++ b/deepgym/src/deepgym/sandbox.py @@ -31,6 +31,7 @@ DEADLINE_GRACE_S, SANDBOX_SETUP, JudgeJob, + local_isolation_kind, local_judge, sandbox_judge, sandbox_uploads, @@ -84,6 +85,22 @@ def __init__( self.exit_code = exit_code +def local_run_result(executed: ExecutionResult, elapsed_ms: float) -> RunResult: + """Build the RunResult of a local run, recording how its solution was isolated. + + Raises: + VerifierError: If the exit code doesn't back the result (see :func:`check_exit`). + """ + result = build_run_result( + executed.verifier_result, + elapsed_ms, + 'local', + stderr=executed.stderr, + exit_code=executed.exit_code, + ) + return result.model_copy(update={'isolation': local_isolation_kind()}) + + def check_exit(exit_code: int, score: float, passed: bool | None) -> None: """Accept a verifier's result only if its exit code backs it. @@ -303,7 +320,7 @@ def run_judge_in_sandbox(sandbox: Sandbox, job: JudgeJob) -> ExecutionResult: try: setup = sandbox.exec(f'bash -c {shlex.quote(SANDBOX_SETUP)}', timeout=60) if setup.exit_code != 0: - raise SandboxError(f'Judge setup failed: {setup.output[-2000:]}') + raise SandboxError(f'Judge setup failed: {setup.output}') for content, path in sandbox_uploads(job): sandbox.upload(content, path) except SandboxError: diff --git a/deepgym/src/deepgym/sandboxes/cli.py b/deepgym/src/deepgym/sandboxes/cli.py index a2192820..032b5351 100644 --- a/deepgym/src/deepgym/sandboxes/cli.py +++ b/deepgym/src/deepgym/sandboxes/cli.py @@ -74,7 +74,7 @@ def __init__(self, image: str | None = None) -> None: self.delete() raise SandboxError(f'Container did not start within {_START_TIMEOUT_S}s') from exc if started.returncode != 0: - raise SandboxError(f'Failed to start container: {started.stderr.decode()[-1000:]}') + raise SandboxError(f'Failed to start container: {started.stderr.decode()}') @classmethod def build(cls, context: Path) -> str: @@ -107,7 +107,7 @@ def build(cls, context: Path) -> str: except subprocess.TimeoutExpired as exc: raise SandboxError(f'Building {context} took over {_BUILD_TIMEOUT_S}s') from exc if built.returncode != 0: - raise SandboxError(f'Failed to build {context}: {built.stderr.decode()[-1000:]}') + raise SandboxError(f'Failed to build {context}: {built.stderr.decode()}') return tag @classmethod @@ -165,7 +165,7 @@ def exec( raise SandboxTimeoutError(f'command exceeded {timeout}s', output) # A failed exec may mean the container itself is gone; only the listing can tell. if done.returncode != 0 and self._gone(): - raise SandboxDiedError(f'Container {self.id} stopped: {output[-500:]}', output) + raise SandboxDiedError(f'Container {self.id} stopped: {output}', output) return ExecResult(done.returncode, output) def _gone(self) -> bool: @@ -208,7 +208,7 @@ def _answer(cls, *args: str) -> str: """ done = cls._cli(*args, timeout=30) if done.returncode != 0: - raise SandboxError(done.stderr.decode('utf-8', 'replace').strip()[-500:]) + raise SandboxError(done.stderr.decode('utf-8', 'replace').strip()) return done.stdout.decode('utf-8', 'replace') def upload(self, content: bytes, path: str) -> None: @@ -229,7 +229,7 @@ def upload(self, content: bytes, path: str) -> None: raise SandboxDiedError(f'Container {self.id} stopped uploading {path}') from exc raise SandboxError(f'Upload of {path} timed out') from exc if done.returncode != 0: - message = f'Failed to upload {path}: {done.stderr.decode()[-500:]}' + message = f'Failed to upload {path}: {done.stderr.decode()}' raise SandboxDiedError(message) if self._gone() else SandboxError(message) def delete(self) -> bool: @@ -260,7 +260,7 @@ def remove(cls, name: str) -> None: except subprocess.TimeoutExpired as exc: raise SandboxError(f'Removing {name} timed out') from exc if done.returncode != 0: - raise SandboxError(f'Failed to remove {name}: {done.stderr.decode().strip()[-500:]}') + raise SandboxError(f'Failed to remove {name}: {done.stderr.decode().strip()}') @classmethod def running(cls) -> list[RunningSandbox]: @@ -274,7 +274,7 @@ def running(cls) -> list[RunningSandbox]: except subprocess.TimeoutExpired as exc: raise SandboxError(f'{cls.cli} did not list containers within 30s') from exc if done.returncode != 0: - detail = done.stderr.decode().strip()[-500:] + detail = done.stderr.decode().strip() raise SandboxError(f'{cls.cli} could not list containers: {detail}') listed = cls._parse_listing(done.stdout.decode()) return [box for box in listed if box.id.startswith(_NAME_PREFIX)] diff --git a/deepgym/src/deepgym/sandboxes/daytona/desktop.py b/deepgym/src/deepgym/sandboxes/daytona/desktop.py index db3d5023..2193bae5 100644 --- a/deepgym/src/deepgym/sandboxes/daytona/desktop.py +++ b/deepgym/src/deepgym/sandboxes/daytona/desktop.py @@ -216,7 +216,7 @@ def _confirm(step: str, done: ExecResult) -> None: SandboxError: Otherwise. """ if done.exit_code != 0 or done.output.split()[-1:] != [_LOCKED]: - raise SandboxError(f'Refusing the desktop, its {step} failed: {done.output[-500:]}') + raise SandboxError(f'Refusing the desktop, its {step} failed: {done.output}') class DaytonaDesktop: diff --git a/deepgym/src/deepgym/sandboxes/daytona/sandbox.py b/deepgym/src/deepgym/sandboxes/daytona/sandbox.py index 3d572ed0..0d0677cf 100644 --- a/deepgym/src/deepgym/sandboxes/daytona/sandbox.py +++ b/deepgym/src/deepgym/sandboxes/daytona/sandbox.py @@ -211,7 +211,7 @@ def __init__(self, daytona: Daytona, env: Environment | MultiTurnEnvironment) -> stage = probe.output.strip() if probe.exit_code != 0 or not stage: self.delete() - raise SandboxError(f'Could not prepare sandbox {self.id}: {probe.output[-500:]}') + raise SandboxError(f'Could not prepare sandbox {self.id}: {probe.output}') if stage != 'root': self._sudo, self._stage = '/usr/bin/sudo -n -E ', stage @@ -323,7 +323,7 @@ def upload(self, content: bytes, path: str) -> None: timeout=60, ) if moved.exit_code != 0: - raise SandboxError(f'Failed to upload {path}: {moved.output[-500:]}') + raise SandboxError(f'Failed to upload {path}: {moved.output}') def delete(self) -> bool: """Delete the sandbox; errors are logged. Return False if Daytona reported a failure.""" diff --git a/deepgym/src/deepgym/sandboxes/modal/sandbox.py b/deepgym/src/deepgym/sandboxes/modal/sandbox.py index 248eb8f0..90e70342 100644 --- a/deepgym/src/deepgym/sandboxes/modal/sandbox.py +++ b/deepgym/src/deepgym/sandboxes/modal/sandbox.py @@ -89,7 +89,7 @@ def exec( raise stopped_error(message, self._expires_at) from exc raise if exit_code != 0 and self._stopped(): - message = f'Modal sandbox {self.id} was terminated: {output[-500:]}' + message = f'Modal sandbox {self.id} was terminated: {output}' raise stopped_error(message, self._expires_at, output) # Modal kills an exec at its timeout and reports only a failed exit. if timeout and exit_code != 0 and time.monotonic() - start >= timeout: diff --git a/deepgym/src/deepgym/session.py b/deepgym/src/deepgym/session.py index 942237cb..ee733a13 100644 --- a/deepgym/src/deepgym/session.py +++ b/deepgym/src/deepgym/session.py @@ -12,6 +12,7 @@ import base64 import contextlib import errno +import functools import json import logging import math @@ -39,6 +40,7 @@ SandboxDiedError, SandboxError, SandboxExpiredError, + SessionClosedError, TimeoutError, VerifierError, is_infrastructure_failure, @@ -232,6 +234,16 @@ def close(self) -> None: ... +@functools.cache +def _warn_unisolated_actions() -> None: + """Say once per process that local sessions run agent actions on the host unsandboxed + (their results record it as ``isolation='none'``).""" + logger.warning( + 'A local session runs agent actions on this machine with no sandbox; use a sandbox ' + 'provider for any agent you do not trust' + ) + + class LocalBackend: """Run actions in a host temp directory. Dev/testing only: no isolation, no desktop.""" @@ -239,6 +251,7 @@ class LocalBackend: python = shlex.quote(sys.executable) def __init__(self) -> None: + _warn_unisolated_actions() self._dir = Path(tempfile.mkdtemp(prefix='deepgym_session_')) self._groups: set[int] = set() # deepgym_solution for verifiers, outside the work directory the agent writes to. @@ -474,7 +487,7 @@ def __init__( timeout=_TRANSFER_TIMEOUT_S, ) if users.exit_code != 0: - raise SandboxError(f'Could not create the session users: {users.output[-500:]}') + raise SandboxError(f'Could not create the session users: {users.output}') *_, ids, real = users.output.strip().splitlines() (agent_uid, agent_gid, verifier_uid, verifier_gid, solver, solver_uid, solver_gid, setpriv, nsenter, sudo) = ids.split()[-10:] # fmt: skip @@ -494,7 +507,7 @@ def __init__( timeout=_TRANSFER_TIMEOUT_S, ) if owned.exit_code != 0: - raise SandboxError(f'Could not hand over {self._workdir}: {owned.output[-500:]}') + raise SandboxError(f'Could not hand over {self._workdir}: {owned.output}') # Root and verifiers run what TRUSTED_PATH finds, so only root may change any of # it. Checked once the agent owns its work directory, which no folder on it may # lead into; anything but a clean pass refuses the sandbox. @@ -506,7 +519,7 @@ def __init__( timeout=60, ) if sealed.exit_code != 0 or sealed.output.strip() != sealed_path.SEALED: - raise SandboxError(f'Refusing the sandbox: {sealed.output[-500:]}') + raise SandboxError(f'Refusing the sandbox: {sealed.output}') self._as_agent = _drop_to(setpriv, agent_uid, agent_gid) self._agent_drop = self._as_agent self._agent_namespace = '' @@ -608,7 +621,7 @@ def _make_offline_namespace(self) -> None: if made.exit_code != 0: raise SandboxError( 'Could not isolate the session network; the image needs util-linux ' - f'(unshare, nsenter, mount): {made.output[-500:]}' + f'(unshare, nsenter, mount): {made.output}' ) def exec(self, command: str, timeout: int, env: dict[str, str] | None = None) -> ExecResult: @@ -726,7 +739,7 @@ def archive_workdir(self) -> bytes: timeout=_TRANSFER_TIMEOUT_S, ) if packed.exit_code != 0: - raise ActionError(f'Failed to archive {self._workdir}: {packed.output[-500:]}') + raise ActionError(f'Failed to archive {self._workdir}: {packed.output}') encoded = self._sandbox.exec( trusted(f'base64 {staged}; status=$?; rm -f {staged}; exit $status'), env=self._clear, @@ -734,7 +747,7 @@ def archive_workdir(self) -> bytes: ) try: if encoded.exit_code != 0: - raise ValueError(encoded.output[-500:]) + raise ValueError(encoded.output) return base64.b64decode(''.join(encoded.output.split()), validate=True) except ValueError as exc: raise SandboxError(f'Failed to read the archive: {exc}') from exc @@ -764,7 +777,7 @@ def restore_workdir(self, archive: bytes) -> None: timeout=_TRANSFER_TIMEOUT_S, ) if done.exit_code != 0: - raise SandboxError(f'Failed to restore {self._workdir}: {done.output[-500:]}') + raise SandboxError(f'Failed to restore {self._workdir}: {done.output}') def write_file(self, path: str, content: bytes) -> None: """Write *content* to *path* under the work directory, as the session user. @@ -788,9 +801,9 @@ def write_file(self, path: str, content: bytes) -> None: timeout=60, ) if done.exit_code == 3: - raise ActionError(f'Failed to write {path}: {done.output[-500:]}') + raise ActionError(f'Failed to write {path}: {done.output}') if done.exit_code != 0: - raise SandboxError(f'Failed to write {path}: {done.output[-500:]}') + raise SandboxError(f'Failed to write {path}: {done.output}') def verify(self, code: str, timeout: int, env: dict[str, str] | None = None) -> VerifierOutput: """Run verifier *code* as the verifier user, from a file only that user can read. @@ -1005,6 +1018,8 @@ def __init__(self, env: MultiTurnEnvironment, backend: SessionBackend) -> None: self._unsettled: list[JsonValue] = [] # One sandbox, one actor at a time: concurrent step/score/close calls queue up. self._lock = threading.Lock() + # Set by close() whether or not the delete succeeds (see _check_open). + self._closed = False @property def done(self) -> bool: @@ -1049,11 +1064,12 @@ def start(self) -> Observation: """Write files, start the desktop, run setup, and return the first observation. Raises: - DeepGymError: If the session was already started. + DeepGymError: If the session was already started or is closed. SandboxError: If setup exits non-zero. TimeoutError: If setup exceeds ``setup_timeout``. """ with self._lock: + self._check_open() if self._obs is not None: raise DeepGymError('Session already started; open a new session per episode') env = self._env @@ -1106,7 +1122,7 @@ def step(self, action: Action, *, timeout: float | None = None) -> StepResult: separate; provider transport can take its own cleanup grace after a deadline. Raises: - DeepGymError: If the session is not running. + DeepGymError: If the session is not running or is closed. ActionError: If the action is not allowed here, names no declared tool or has arguments its tool rejects, the desktop rejects it, or the agent's own files are in the way of a write (the episode goes on, unless they blocked the @@ -1120,6 +1136,7 @@ def step(self, action: Action, *, timeout: float | None = None) -> StepResult: raise ValueError('Step timeout must be finite and positive') deadline = time.monotonic() + timeout if timeout is not None else None with self._lock: + self._check_open() if self._obs is None or self._done: raise DeepGymError('Session is not running; call start() or open a new session') if action.action_type not in self._env.allowed_actions: @@ -1179,11 +1196,12 @@ def run_cli(self, request: CliRequest, on_event: EventObserver) -> CliExecution: Captured stdout, events, exit code, and any budget stop reason. Raises: - DeepGymError: If the session is not running. + DeepGymError: If the session is not running or is closed. ActionError: If the environment disallows shell execution or CLI provider egress. SandboxError: If the backend cannot isolate or supervise the CLI safely. """ with self._lock: + self._check_open() if self._obs is None or self._done: raise DeepGymError('Session is not running; call start() or open a new session') if not {'bash', 'cli'} & self._env.allowed_actions: @@ -1240,10 +1258,12 @@ def finalize_cli(self) -> None: Already-ended episodes retain their terminal observation, including unknown state. Raises: + DeepGymError: If the session is closed. SandboxError: If containment is unavailable or cannot be stopped. The stored failure has no verdict and prevents subsequent verification. """ with self._lock: + self._check_open() if self._cli_finalized: return was_done = self._done @@ -1298,6 +1318,7 @@ def score(self) -> RunResult: (``unsettled_write``), whatever the verifier would say, and the verifier doesn't run. Raises: + DeepGymError: If the session is closed. VerifierError: If, before it read the agent's output, the verifier timed out or its output is not valid protocol JSON or isn't backed by its exit code; or its code couldn't be uploaded. After, any of these is the agent's judged 0 @@ -1305,57 +1326,76 @@ def score(self) -> RunResult: TimeoutError: If the verifier timed out and the provider returned nothing. """ with self._lock: - if self._failed_result is not None: - return self._failed_result - if not self._env.final_verifier_code: - return RunResult( - score=0.0, - passed=False, - output='No final verifier provided', - stderr='', - exit_code=0, - execution_time_ms=0.0, - sandbox_id=self.sandbox_id, - error_type='no_verifier', - ) - if unsettled := self._settle(): - return _unsettled_result(unsettled, self._unsettled, self.sandbox_id) - start = time.perf_counter() - acted = bool(self._trajectory.steps) - try: - code = self._env.final_verifier_code - (exit_code, stdout, stderr), consulted = self._verify(code, acted=acted) - except SandboxDiedError as exc: - if not acted: - return error_result(exc, self.sandbox_id) - return failure_result(exc, self.sandbox_id) - except VerifierUploadError as exc: - if not acted: - raise VerifierError(str(exc)) from exc - return failure_result(exc, self.sandbox_id) - except Exception as exc: - if acted and self._ran_out_the_clock(exc): - return failure_result(exc, self.sandbox_id, exhausted=True) + return self._stamped(self._score()) + + def _score(self) -> RunResult: + """:meth:`score`, under its lock.""" + if self._failed_result is not None: # fixed already: no sandbox needed + return self._failed_result + self._check_open() + if not self._env.final_verifier_code: + return RunResult( + score=0.0, + passed=False, + output='No final verifier provided', + stderr='', + exit_code=0, + execution_time_ms=0.0, + sandbox_id=self.sandbox_id, + error_type='no_verifier', + ) + if unsettled := self._settle(): + return _unsettled_result(unsettled, self._unsettled, self.sandbox_id) + start = time.perf_counter() + acted = bool(self._trajectory.steps) + try: + code = self._env.final_verifier_code + (exit_code, stdout, stderr), consulted = self._verify(code, acted=acted) + except SandboxDiedError as exc: + if not acted: + return error_result(exc, self.sandbox_id) + return failure_result(exc, self.sandbox_id) + except VerifierUploadError as exc: + if not acted: + raise VerifierError(str(exc)) from exc + return failure_result(exc, self.sandbox_id) + except Exception as exc: + if acted and self._ran_out_the_clock(exc): + return failure_result(exc, self.sandbox_id, exhausted=True) + raise + stderr = stderr.strip() + elapsed_ms = (time.perf_counter() - start) * 1000 + try: + verdict = parse_verifier_output(stdout, f'exit_code={exit_code} stderr: {stderr!r}') + result = build_run_result( + verdict, elapsed_ms, self.sandbox_id, stderr=stderr, exit_code=exit_code + ) + except VerifierError as exc: + if not consulted or _launch_failed(stdout): raise - stderr = stderr.strip() - elapsed_ms = (time.perf_counter() - start) * 1000 - try: - verdict = parse_verifier_output(stdout, f'exit_code={exit_code} stderr: {stderr!r}') - result = build_run_result( - verdict, elapsed_ms, self.sandbox_id, stderr=stderr, exit_code=exit_code - ) - except VerifierError as exc: - if not consulted or _launch_failed(stdout): - raise - result = _failed_after_reading(exc, elapsed_ms, self.sandbox_id, stderr) - if consulted and not result.has_verdict and result.error_type != 'sandbox_error': - detail = VerifierError(f'{result.error_type}: {result.output}') - return _failed_after_reading(detail, elapsed_ms, self.sandbox_id, stderr) - return result + result = _failed_after_reading(exc, elapsed_ms, self.sandbox_id, stderr) + if consulted and not result.has_verdict and result.error_type != 'sandbox_error': + detail = VerifierError(f'{result.error_type}: {result.output}') + return _failed_after_reading(detail, elapsed_ms, self.sandbox_id, stderr) + return result + + def _stamped(self, result: RunResult) -> RunResult: + """*result*, with how the agent's actions were isolated: a :class:`LocalBackend` + runs them as plain host processes (``none``), whatever its verifier isolates. A + sandbox provider's result keeps None: there they run as a separate user.""" + if isinstance(self._backend, LocalBackend): + return result.model_copy(update={'isolation': 'none'}) + return result def close(self) -> None: - """Release the backend, waiting for any in-flight step to finish.""" + """Release the backend, waiting for any in-flight step to finish. + + The session counts as closed from here on, even if the delete fails, so a call + queued behind this one is refused (see :meth:`_check_open`). Calling close() again + retries a failed delete. + """ with self._lock: + self._closed = True self._backend.close() def __enter__(self) -> Session: @@ -1373,6 +1413,16 @@ def __exit__(self, *args: object) -> None: 'Could not delete sandbox %s; it may still exist', self.sandbox_id, exc_info=True ) + def _check_open(self) -> None: + """Refuse to use the backend after close(): its sandbox may be deleted, and a step + failing there would be scored as the agent's doing when it was the host's. + + Raises: + SessionClosedError: If the session is closed. + """ + if self._closed: + raise SessionClosedError('Session is closed; open a new session') + def _apply( self, action: Action, @@ -1754,7 +1804,7 @@ def _kill_group(group: int) -> None: def _checked(what: str, result: ExecResult) -> str: """Return *result*'s output, or raise if the command failed.""" if result.exit_code != 0: - raise SandboxError(f'{what} exited {result.exit_code}: {result.output[-2000:]}') + raise SandboxError(f'{what} exited {result.exit_code}: {result.output}') return result.output diff --git a/deepgym/src/deepgym/solution_runtime.py b/deepgym/src/deepgym/solution_runtime.py index 8176d5e7..a9d21f61 100644 --- a/deepgym/src/deepgym/solution_runtime.py +++ b/deepgym/src/deepgym/solution_runtime.py @@ -566,7 +566,7 @@ def _preflight() -> None: start_new_session=True, ) if probe.returncode != 0: - error = probe.stderr.decode('utf-8', 'replace')[-500:] + error = probe.stderr.decode('utf-8', 'replace') raise OSError(f'the isolation launcher exited {probe.returncode}: {error}') _preflight_passed = True @@ -889,7 +889,7 @@ def _apply_hunks(name: str, text: str, lines: list[str], index: int) -> tuple[st new_left -= 1 continue if tag not in ' -': - raise ValueError(f'{name}: unexpected line in hunk: {line[:80]!r}') + raise ValueError(f'{name}: unexpected line in hunk: {line!r}') if pos >= len(source) or source[pos].rstrip('\r\n') != body: raise ValueError(f'{name}: hunk does not match line {pos + 1}') if tag == ' ': @@ -900,7 +900,7 @@ def _apply_hunks(name: str, text: str, lines: list[str], index: int) -> tuple[st if old_left < 0 or new_left < 0: raise ValueError(f'{name}: hunk line counts do not match its header') if index < len(lines) and lines[index].startswith('@@ -'): - raise ValueError(f'{name}: malformed hunk header {lines[index][:80]!r}') + raise ValueError(f'{name}: malformed hunk header {lines[index]!r}') if index == first: raise ValueError(f'{name}: no hunk follows the file header') return ''.join(output + source[pos:]), index @@ -1293,7 +1293,7 @@ def resolve(target: dict) -> Any: 'id': message.get('id'), 'ok': False, 'type': type(exc).__name__, - 'message': str(exc)[:2000], + 'message': str(exc), } replies.write(json.dumps(reply) + '\n') replies.flush() diff --git a/deepgym/tests/golden/traces/atif/trace0001.json b/deepgym/tests/golden/traces/atif/trace0001.json index b03ea7f9..9c06fee0 100644 --- a/deepgym/tests/golden/traces/atif/trace0001.json +++ b/deepgym/tests/golden/traces/atif/trace0001.json @@ -220,7 +220,8 @@ "seed": null, "truncated": false, "error_type": null, - "cases": null + "cases": null, + "isolation": null }, "prior_results": [], "error": null, diff --git a/deepgym/tests/golden/traces/summary.jsonl b/deepgym/tests/golden/traces/summary.jsonl index c0fe4c59..ac684207 100644 --- a/deepgym/tests/golden/traces/summary.jsonl +++ b/deepgym/tests/golden/traces/summary.jsonl @@ -1,3 +1,3 @@ -{"trace_id": "trace0001", "env_name": "write_notes", "env_variant": 2, "agent": "react-shell", "model": "Qwen/Qwen3-8B", "provider": "local", "status": "complete", "has_verdict": true, "score": 1.0, "passed": true, "steps": 3, "started_at": "2026-10-01T12:00:00+00:00", "trace": {"schema_version": "1.1", "trace_id": "trace0001", "task": "Write hello to notes/out.txt, then finish.", "provenance": {"env": {"name": "write_notes", "variant": 2, "seed": 7, "environment_sha256": "7f142979bcdf5748ac3baa872a5db2a58d701da9bb29b7451d747e8a3777b6f9", "task_sha256": "1bbf96220c257b1281f09efdf074dc9cb437d3f4401d77c008308bfd72fccc28", "verifier_sha256": "5d6ee51bb39cafa8308963062208847333565120aa1e94980872375cd011d158", "step_verifier_sha256": "e4a424cca79d7c48e6fd4bc2827e14523db51284f401d9c0321d43117878860b", "reference_sha256": "a8bad614127cbaaaaa67e14952ae0fc6c0dce71bdff0822d038556b7dfbe3431", "image": "python:3.12-slim@sha256:abababababababababababababababababababababababababababababababab", "image_digest": "sha256:abababababababababababababababababababababababababababababababab", "digest_unavailable": null, "snapshot": null}, "agent": {"name": "react-shell", "version": "1.2.0", "model": "Qwen/Qwen3-8B", "sampling": {"temperature": 0.7, "top_p": 0.95}}, "provider": "local", "deepgym_version": "0.4.1", "deepgym_commit": null, "deepgym_dirty": null, "started_at": "2026-10-01T12:00:00Z"}, "initial_observation": {"content": "$ ", "step": 0, "done": false, "screenshot": "iVBORw0KGgogc2FtcGxlIHNjcmVlbnNob3Q=", "metadata": null}, "steps": [{"index": 1, "action": {"action_type": "bash", "content": "mkdir -p notes && echo hello > notes/out.txt", "events": [], "x": null, "y": null, "to_x": null, "to_y": null, "path": null, "amount": 1, "tool": null, "arguments": {}}, "messages": [{"role": "system", "content": "You are a shell agent.", "token_ids": null, "logprobs": null}, {"role": "user", "content": "Write hello to notes/out.txt, then finish.\n$ ", "token_ids": null, "logprobs": null}, {"role": "assistant", "content": "mkdir -p notes && echo hello > notes/out.txt", "token_ids": [11, 12, 13], "logprobs": [-0.25, -0.5, -0.125]}], "observation": {"content": "", "step": 1, "done": false, "screenshot": null, "metadata": {"exit_code": 0}}, "applied": true, "reward": 0.5, "step_verifier": {"score": 0.5}, "error": null, "timestamp": "2026-10-01T12:00:01Z", "duration_ms": 40.0, "exit_code": 0}, {"index": 2, "action": {"action_type": "write_file", "content": "x", "events": [], "x": null, "y": null, "to_x": null, "to_y": null, "path": "notes/extra.txt", "amount": 1, "tool": null, "arguments": {}}, "messages": [{"role": "tool", "content": "exit 0", "token_ids": null, "logprobs": null}, {"role": "assistant", "content": "write notes/extra.txt", "token_ids": null, "logprobs": null}], "observation": null, "applied": false, "reward": null, "step_verifier": null, "error": "ActionError: Action 'write_file' not allowed here.", "timestamp": "2026-10-01T12:00:02Z", "duration_ms": 1.0, "exit_code": null}, {"index": 3, "action": {"action_type": "done", "content": "", "events": [], "x": null, "y": null, "to_x": null, "to_y": null, "path": null, "amount": 1, "tool": null, "arguments": {}}, "messages": [{"role": "user", "content": "write_file is not allowed.", "token_ids": null, "logprobs": null}, {"role": "assistant", "content": "done", "token_ids": null, "logprobs": null}], "observation": {"content": "", "step": 2, "done": true, "screenshot": null, "metadata": null}, "applied": true, "reward": 0.5, "step_verifier": {"score": 0.5}, "error": null, "timestamp": "2026-10-01T12:00:03Z", "duration_ms": 30.0, "exit_code": null}], "result": {"score": 1.0, "passed": true, "output": "notes/out.txt says hello", "stderr": "", "exit_code": 0, "execution_time_ms": 12.5, "sandbox_id": "local-session", "reward_components": null, "metrics": null, "seed": null, "truncated": false, "error_type": null, "cases": null}, "prior_results": [], "error": null, "finished_at": "2026-10-01T12:00:04Z", "turns": [[1], [2], [3]], "status": "complete", "has_verdict": true}} +{"trace_id": "trace0001", "env_name": "write_notes", "env_variant": 2, "agent": "react-shell", "model": "Qwen/Qwen3-8B", "provider": "local", "status": "complete", "has_verdict": true, "score": 1.0, "passed": true, "steps": 3, "started_at": "2026-10-01T12:00:00+00:00", "trace": {"schema_version": "1.1", "trace_id": "trace0001", "task": "Write hello to notes/out.txt, then finish.", "provenance": {"env": {"name": "write_notes", "variant": 2, "seed": 7, "environment_sha256": "7f142979bcdf5748ac3baa872a5db2a58d701da9bb29b7451d747e8a3777b6f9", "task_sha256": "1bbf96220c257b1281f09efdf074dc9cb437d3f4401d77c008308bfd72fccc28", "verifier_sha256": "5d6ee51bb39cafa8308963062208847333565120aa1e94980872375cd011d158", "step_verifier_sha256": "e4a424cca79d7c48e6fd4bc2827e14523db51284f401d9c0321d43117878860b", "reference_sha256": "a8bad614127cbaaaaa67e14952ae0fc6c0dce71bdff0822d038556b7dfbe3431", "image": "python:3.12-slim@sha256:abababababababababababababababababababababababababababababababab", "image_digest": "sha256:abababababababababababababababababababababababababababababababab", "digest_unavailable": null, "snapshot": null}, "agent": {"name": "react-shell", "version": "1.2.0", "model": "Qwen/Qwen3-8B", "sampling": {"temperature": 0.7, "top_p": 0.95}}, "provider": "local", "deepgym_version": "0.4.1", "deepgym_commit": null, "deepgym_dirty": null, "started_at": "2026-10-01T12:00:00Z"}, "initial_observation": {"content": "$ ", "step": 0, "done": false, "screenshot": "iVBORw0KGgogc2FtcGxlIHNjcmVlbnNob3Q=", "metadata": null}, "steps": [{"index": 1, "action": {"action_type": "bash", "content": "mkdir -p notes && echo hello > notes/out.txt", "events": [], "x": null, "y": null, "to_x": null, "to_y": null, "path": null, "amount": 1, "tool": null, "arguments": {}}, "messages": [{"role": "system", "content": "You are a shell agent.", "token_ids": null, "logprobs": null}, {"role": "user", "content": "Write hello to notes/out.txt, then finish.\n$ ", "token_ids": null, "logprobs": null}, {"role": "assistant", "content": "mkdir -p notes && echo hello > notes/out.txt", "token_ids": [11, 12, 13], "logprobs": [-0.25, -0.5, -0.125]}], "observation": {"content": "", "step": 1, "done": false, "screenshot": null, "metadata": {"exit_code": 0}}, "applied": true, "reward": 0.5, "step_verifier": {"score": 0.5}, "error": null, "timestamp": "2026-10-01T12:00:01Z", "duration_ms": 40.0, "exit_code": 0}, {"index": 2, "action": {"action_type": "write_file", "content": "x", "events": [], "x": null, "y": null, "to_x": null, "to_y": null, "path": "notes/extra.txt", "amount": 1, "tool": null, "arguments": {}}, "messages": [{"role": "tool", "content": "exit 0", "token_ids": null, "logprobs": null}, {"role": "assistant", "content": "write notes/extra.txt", "token_ids": null, "logprobs": null}], "observation": null, "applied": false, "reward": null, "step_verifier": null, "error": "ActionError: Action 'write_file' not allowed here.", "timestamp": "2026-10-01T12:00:02Z", "duration_ms": 1.0, "exit_code": null}, {"index": 3, "action": {"action_type": "done", "content": "", "events": [], "x": null, "y": null, "to_x": null, "to_y": null, "path": null, "amount": 1, "tool": null, "arguments": {}}, "messages": [{"role": "user", "content": "write_file is not allowed.", "token_ids": null, "logprobs": null}, {"role": "assistant", "content": "done", "token_ids": null, "logprobs": null}], "observation": {"content": "", "step": 2, "done": true, "screenshot": null, "metadata": null}, "applied": true, "reward": 0.5, "step_verifier": {"score": 0.5}, "error": null, "timestamp": "2026-10-01T12:00:03Z", "duration_ms": 30.0, "exit_code": null}], "result": {"score": 1.0, "passed": true, "output": "notes/out.txt says hello", "stderr": "", "exit_code": 0, "execution_time_ms": 12.5, "sandbox_id": "local-session", "reward_components": null, "metrics": null, "seed": null, "truncated": false, "error_type": null, "cases": null, "isolation": null}, "prior_results": [], "error": null, "finished_at": "2026-10-01T12:00:04Z", "turns": [[1], [2], [3]], "status": "complete", "has_verdict": true}} {"trace_id": "trace0002", "env_name": "write_notes", "env_variant": 2, "agent": "react-shell", "model": "Qwen/Qwen3-8B", "provider": "local", "status": "incomplete", "has_verdict": false, "score": null, "passed": null, "steps": 1, "started_at": "2026-10-01T12:00:00+00:00", "trace": {"schema_version": "1.1", "trace_id": "trace0002", "task": "Write hello to notes/out.txt, then finish.", "provenance": {"env": {"name": "write_notes", "variant": 2, "seed": 7, "environment_sha256": "7f142979bcdf5748ac3baa872a5db2a58d701da9bb29b7451d747e8a3777b6f9", "task_sha256": "1bbf96220c257b1281f09efdf074dc9cb437d3f4401d77c008308bfd72fccc28", "verifier_sha256": "5d6ee51bb39cafa8308963062208847333565120aa1e94980872375cd011d158", "step_verifier_sha256": "e4a424cca79d7c48e6fd4bc2827e14523db51284f401d9c0321d43117878860b", "reference_sha256": "a8bad614127cbaaaaa67e14952ae0fc6c0dce71bdff0822d038556b7dfbe3431", "image": "python:3.12-slim@sha256:abababababababababababababababababababababababababababababababab", "image_digest": "sha256:abababababababababababababababababababababababababababababababab", "digest_unavailable": null, "snapshot": null}, "agent": {"name": "react-shell", "version": "1.2.0", "model": "Qwen/Qwen3-8B", "sampling": {"temperature": 0.7, "top_p": 0.95}}, "provider": "local", "deepgym_version": "0.4.1", "deepgym_commit": null, "deepgym_dirty": null, "started_at": "2026-10-01T12:00:00Z"}, "initial_observation": {"content": "$ ", "step": 0, "done": false, "screenshot": "iVBORw0KGgogc2FtcGxlIHNjcmVlbnNob3Q=", "metadata": null}, "steps": [{"index": 1, "action": {"action_type": "bash", "content": "mkdir -p notes && echo hello > notes/out.txt", "events": [], "x": null, "y": null, "to_x": null, "to_y": null, "path": null, "amount": 1, "tool": null, "arguments": {}}, "messages": [], "observation": {"content": "", "step": 1, "done": false, "screenshot": null, "metadata": {"exit_code": 0}}, "applied": true, "reward": 0.5, "step_verifier": {"score": 0.5}, "error": null, "timestamp": "2026-10-01T12:00:01Z", "duration_ms": 40.0, "exit_code": 0}], "result": null, "prior_results": [], "error": "SandboxError: sandbox vanished", "finished_at": null, "turns": [[1]], "status": "incomplete", "has_verdict": false}} -{"trace_id": "trace0003", "env_name": "write_notes", "env_variant": 2, "agent": "react-shell", "model": "Qwen/Qwen3-8B", "provider": "local", "status": "complete", "has_verdict": false, "score": null, "passed": null, "steps": 3, "started_at": "2026-10-01T12:00:00+00:00", "trace": {"schema_version": "1.1", "trace_id": "trace0003", "task": "Write hello to notes/out.txt, then finish.", "provenance": {"env": {"name": "write_notes", "variant": 2, "seed": 7, "environment_sha256": "7f142979bcdf5748ac3baa872a5db2a58d701da9bb29b7451d747e8a3777b6f9", "task_sha256": "1bbf96220c257b1281f09efdf074dc9cb437d3f4401d77c008308bfd72fccc28", "verifier_sha256": "5d6ee51bb39cafa8308963062208847333565120aa1e94980872375cd011d158", "step_verifier_sha256": "e4a424cca79d7c48e6fd4bc2827e14523db51284f401d9c0321d43117878860b", "reference_sha256": "a8bad614127cbaaaaa67e14952ae0fc6c0dce71bdff0822d038556b7dfbe3431", "image": "python:3.12-slim@sha256:abababababababababababababababababababababababababababababababab", "image_digest": "sha256:abababababababababababababababababababababababababababababababab", "digest_unavailable": null, "snapshot": null}, "agent": {"name": "react-shell", "version": "1.2.0", "model": "Qwen/Qwen3-8B", "sampling": {"temperature": 0.7, "top_p": 0.95}}, "provider": "local", "deepgym_version": "0.4.1", "deepgym_commit": null, "deepgym_dirty": null, "started_at": "2026-10-01T12:00:00Z"}, "initial_observation": {"content": "$ ", "step": 0, "done": false, "screenshot": "iVBORw0KGgogc2FtcGxlIHNjcmVlbnNob3Q=", "metadata": null}, "steps": [{"index": 1, "action": {"action_type": "bash", "content": "mkdir -p notes && echo hello > notes/out.txt", "events": [], "x": null, "y": null, "to_x": null, "to_y": null, "path": null, "amount": 1, "tool": null, "arguments": {}}, "messages": [{"role": "system", "content": "You are a shell agent.", "token_ids": null, "logprobs": null}, {"role": "user", "content": "Write hello to notes/out.txt, then finish.\n$ ", "token_ids": null, "logprobs": null}, {"role": "assistant", "content": "mkdir -p notes && echo hello > notes/out.txt", "token_ids": [11, 12, 13], "logprobs": [-0.25, -0.5, -0.125]}], "observation": {"content": "", "step": 1, "done": false, "screenshot": null, "metadata": {"exit_code": 0}}, "applied": true, "reward": 0.5, "step_verifier": {"score": 0.5}, "error": null, "timestamp": "2026-10-01T12:00:01Z", "duration_ms": 40.0, "exit_code": 0}, {"index": 2, "action": {"action_type": "write_file", "content": "x", "events": [], "x": null, "y": null, "to_x": null, "to_y": null, "path": "notes/extra.txt", "amount": 1, "tool": null, "arguments": {}}, "messages": [{"role": "tool", "content": "exit 0", "token_ids": null, "logprobs": null}, {"role": "assistant", "content": "write notes/extra.txt", "token_ids": null, "logprobs": null}], "observation": null, "applied": false, "reward": null, "step_verifier": null, "error": "ActionError: Action 'write_file' not allowed here.", "timestamp": "2026-10-01T12:00:02Z", "duration_ms": 1.0, "exit_code": null}, {"index": 3, "action": {"action_type": "done", "content": "", "events": [], "x": null, "y": null, "to_x": null, "to_y": null, "path": null, "amount": 1, "tool": null, "arguments": {}}, "messages": [{"role": "user", "content": "write_file is not allowed.", "token_ids": null, "logprobs": null}, {"role": "assistant", "content": "done", "token_ids": null, "logprobs": null}], "observation": {"content": "", "step": 2, "done": true, "screenshot": null, "metadata": null}, "applied": true, "reward": 0.5, "step_verifier": {"score": 0.5}, "error": null, "timestamp": "2026-10-01T12:00:03Z", "duration_ms": 30.0, "exit_code": null}], "result": {"score": 0.0, "passed": false, "output": "notes/out.txt says hello", "stderr": "", "exit_code": 2, "execution_time_ms": 12.5, "sandbox_id": "local-session", "reward_components": null, "metrics": null, "seed": null, "truncated": false, "error_type": "sandbox_error", "cases": null}, "prior_results": [], "error": null, "finished_at": "2026-10-01T12:00:04Z", "turns": [[1], [2], [3]], "status": "complete", "has_verdict": false}} +{"trace_id": "trace0003", "env_name": "write_notes", "env_variant": 2, "agent": "react-shell", "model": "Qwen/Qwen3-8B", "provider": "local", "status": "complete", "has_verdict": false, "score": null, "passed": null, "steps": 3, "started_at": "2026-10-01T12:00:00+00:00", "trace": {"schema_version": "1.1", "trace_id": "trace0003", "task": "Write hello to notes/out.txt, then finish.", "provenance": {"env": {"name": "write_notes", "variant": 2, "seed": 7, "environment_sha256": "7f142979bcdf5748ac3baa872a5db2a58d701da9bb29b7451d747e8a3777b6f9", "task_sha256": "1bbf96220c257b1281f09efdf074dc9cb437d3f4401d77c008308bfd72fccc28", "verifier_sha256": "5d6ee51bb39cafa8308963062208847333565120aa1e94980872375cd011d158", "step_verifier_sha256": "e4a424cca79d7c48e6fd4bc2827e14523db51284f401d9c0321d43117878860b", "reference_sha256": "a8bad614127cbaaaaa67e14952ae0fc6c0dce71bdff0822d038556b7dfbe3431", "image": "python:3.12-slim@sha256:abababababababababababababababababababababababababababababababab", "image_digest": "sha256:abababababababababababababababababababababababababababababababab", "digest_unavailable": null, "snapshot": null}, "agent": {"name": "react-shell", "version": "1.2.0", "model": "Qwen/Qwen3-8B", "sampling": {"temperature": 0.7, "top_p": 0.95}}, "provider": "local", "deepgym_version": "0.4.1", "deepgym_commit": null, "deepgym_dirty": null, "started_at": "2026-10-01T12:00:00Z"}, "initial_observation": {"content": "$ ", "step": 0, "done": false, "screenshot": "iVBORw0KGgogc2FtcGxlIHNjcmVlbnNob3Q=", "metadata": null}, "steps": [{"index": 1, "action": {"action_type": "bash", "content": "mkdir -p notes && echo hello > notes/out.txt", "events": [], "x": null, "y": null, "to_x": null, "to_y": null, "path": null, "amount": 1, "tool": null, "arguments": {}}, "messages": [{"role": "system", "content": "You are a shell agent.", "token_ids": null, "logprobs": null}, {"role": "user", "content": "Write hello to notes/out.txt, then finish.\n$ ", "token_ids": null, "logprobs": null}, {"role": "assistant", "content": "mkdir -p notes && echo hello > notes/out.txt", "token_ids": [11, 12, 13], "logprobs": [-0.25, -0.5, -0.125]}], "observation": {"content": "", "step": 1, "done": false, "screenshot": null, "metadata": {"exit_code": 0}}, "applied": true, "reward": 0.5, "step_verifier": {"score": 0.5}, "error": null, "timestamp": "2026-10-01T12:00:01Z", "duration_ms": 40.0, "exit_code": 0}, {"index": 2, "action": {"action_type": "write_file", "content": "x", "events": [], "x": null, "y": null, "to_x": null, "to_y": null, "path": "notes/extra.txt", "amount": 1, "tool": null, "arguments": {}}, "messages": [{"role": "tool", "content": "exit 0", "token_ids": null, "logprobs": null}, {"role": "assistant", "content": "write notes/extra.txt", "token_ids": null, "logprobs": null}], "observation": null, "applied": false, "reward": null, "step_verifier": null, "error": "ActionError: Action 'write_file' not allowed here.", "timestamp": "2026-10-01T12:00:02Z", "duration_ms": 1.0, "exit_code": null}, {"index": 3, "action": {"action_type": "done", "content": "", "events": [], "x": null, "y": null, "to_x": null, "to_y": null, "path": null, "amount": 1, "tool": null, "arguments": {}}, "messages": [{"role": "user", "content": "write_file is not allowed.", "token_ids": null, "logprobs": null}, {"role": "assistant", "content": "done", "token_ids": null, "logprobs": null}], "observation": {"content": "", "step": 2, "done": true, "screenshot": null, "metadata": null}, "applied": true, "reward": 0.5, "step_verifier": {"score": 0.5}, "error": null, "timestamp": "2026-10-01T12:00:03Z", "duration_ms": 30.0, "exit_code": null}], "result": {"score": 0.0, "passed": false, "output": "notes/out.txt says hello", "stderr": "", "exit_code": 2, "execution_time_ms": 12.5, "sandbox_id": "local-session", "reward_components": null, "metrics": null, "seed": null, "truncated": false, "error_type": "sandbox_error", "cases": null, "isolation": null}, "prior_results": [], "error": null, "finished_at": "2026-10-01T12:00:04Z", "turns": [[1], [2], [3]], "status": "complete", "has_verdict": false}} diff --git a/deepgym/tests/golden/traces/traces.jsonl b/deepgym/tests/golden/traces/traces.jsonl index 399d0e70..39bc0f80 100644 --- a/deepgym/tests/golden/traces/traces.jsonl +++ b/deepgym/tests/golden/traces/traces.jsonl @@ -1,3 +1,3 @@ -{"schema_version":"1.1","trace_id":"trace0001","task":"Write hello to notes/out.txt, then finish.","provenance":{"env":{"name":"write_notes","variant":2,"seed":7,"environment_sha256":"7f142979bcdf5748ac3baa872a5db2a58d701da9bb29b7451d747e8a3777b6f9","task_sha256":"1bbf96220c257b1281f09efdf074dc9cb437d3f4401d77c008308bfd72fccc28","verifier_sha256":"5d6ee51bb39cafa8308963062208847333565120aa1e94980872375cd011d158","step_verifier_sha256":"e4a424cca79d7c48e6fd4bc2827e14523db51284f401d9c0321d43117878860b","reference_sha256":"a8bad614127cbaaaaa67e14952ae0fc6c0dce71bdff0822d038556b7dfbe3431","image":"python:3.12-slim@sha256:abababababababababababababababababababababababababababababababab","image_digest":"sha256:abababababababababababababababababababababababababababababababab","digest_unavailable":null,"snapshot":null},"agent":{"name":"react-shell","version":"1.2.0","model":"Qwen/Qwen3-8B","sampling":{"temperature":0.7,"top_p":0.95}},"provider":"local","deepgym_version":"0.4.1","deepgym_commit":null,"deepgym_dirty":null,"started_at":"2026-10-01T12:00:00Z"},"initial_observation":{"content":"$ ","step":0,"done":false,"screenshot":"iVBORw0KGgogc2FtcGxlIHNjcmVlbnNob3Q=","metadata":null},"steps":[{"index":1,"action":{"action_type":"bash","content":"mkdir -p notes && echo hello > notes/out.txt","events":[],"x":null,"y":null,"to_x":null,"to_y":null,"path":null,"amount":1,"tool":null,"arguments":{}},"messages":[{"role":"system","content":"You are a shell agent.","token_ids":null,"logprobs":null},{"role":"user","content":"Write hello to notes/out.txt, then finish.\n$ ","token_ids":null,"logprobs":null},{"role":"assistant","content":"mkdir -p notes && echo hello > notes/out.txt","token_ids":[11,12,13],"logprobs":[-0.25,-0.5,-0.125]}],"observation":{"content":"","step":1,"done":false,"screenshot":null,"metadata":{"exit_code":0}},"applied":true,"reward":0.5,"step_verifier":{"score":0.5},"error":null,"timestamp":"2026-10-01T12:00:01Z","duration_ms":40.0,"exit_code":0},{"index":2,"action":{"action_type":"write_file","content":"x","events":[],"x":null,"y":null,"to_x":null,"to_y":null,"path":"notes/extra.txt","amount":1,"tool":null,"arguments":{}},"messages":[{"role":"tool","content":"exit 0","token_ids":null,"logprobs":null},{"role":"assistant","content":"write notes/extra.txt","token_ids":null,"logprobs":null}],"observation":null,"applied":false,"reward":null,"step_verifier":null,"error":"ActionError: Action 'write_file' not allowed here.","timestamp":"2026-10-01T12:00:02Z","duration_ms":1.0,"exit_code":null},{"index":3,"action":{"action_type":"done","content":"","events":[],"x":null,"y":null,"to_x":null,"to_y":null,"path":null,"amount":1,"tool":null,"arguments":{}},"messages":[{"role":"user","content":"write_file is not allowed.","token_ids":null,"logprobs":null},{"role":"assistant","content":"done","token_ids":null,"logprobs":null}],"observation":{"content":"","step":2,"done":true,"screenshot":null,"metadata":null},"applied":true,"reward":0.5,"step_verifier":{"score":0.5},"error":null,"timestamp":"2026-10-01T12:00:03Z","duration_ms":30.0,"exit_code":null}],"result":{"score":1.0,"passed":true,"output":"notes/out.txt says hello","stderr":"","exit_code":0,"execution_time_ms":12.5,"sandbox_id":"local-session","reward_components":null,"metrics":null,"seed":null,"truncated":false,"error_type":null,"cases":null},"prior_results":[],"error":null,"finished_at":"2026-10-01T12:00:04Z","turns":[[1],[2],[3]],"status":"complete","has_verdict":true} +{"schema_version":"1.1","trace_id":"trace0001","task":"Write hello to notes/out.txt, then finish.","provenance":{"env":{"name":"write_notes","variant":2,"seed":7,"environment_sha256":"7f142979bcdf5748ac3baa872a5db2a58d701da9bb29b7451d747e8a3777b6f9","task_sha256":"1bbf96220c257b1281f09efdf074dc9cb437d3f4401d77c008308bfd72fccc28","verifier_sha256":"5d6ee51bb39cafa8308963062208847333565120aa1e94980872375cd011d158","step_verifier_sha256":"e4a424cca79d7c48e6fd4bc2827e14523db51284f401d9c0321d43117878860b","reference_sha256":"a8bad614127cbaaaaa67e14952ae0fc6c0dce71bdff0822d038556b7dfbe3431","image":"python:3.12-slim@sha256:abababababababababababababababababababababababababababababababab","image_digest":"sha256:abababababababababababababababababababababababababababababababab","digest_unavailable":null,"snapshot":null},"agent":{"name":"react-shell","version":"1.2.0","model":"Qwen/Qwen3-8B","sampling":{"temperature":0.7,"top_p":0.95}},"provider":"local","deepgym_version":"0.4.1","deepgym_commit":null,"deepgym_dirty":null,"started_at":"2026-10-01T12:00:00Z"},"initial_observation":{"content":"$ ","step":0,"done":false,"screenshot":"iVBORw0KGgogc2FtcGxlIHNjcmVlbnNob3Q=","metadata":null},"steps":[{"index":1,"action":{"action_type":"bash","content":"mkdir -p notes && echo hello > notes/out.txt","events":[],"x":null,"y":null,"to_x":null,"to_y":null,"path":null,"amount":1,"tool":null,"arguments":{}},"messages":[{"role":"system","content":"You are a shell agent.","token_ids":null,"logprobs":null},{"role":"user","content":"Write hello to notes/out.txt, then finish.\n$ ","token_ids":null,"logprobs":null},{"role":"assistant","content":"mkdir -p notes && echo hello > notes/out.txt","token_ids":[11,12,13],"logprobs":[-0.25,-0.5,-0.125]}],"observation":{"content":"","step":1,"done":false,"screenshot":null,"metadata":{"exit_code":0}},"applied":true,"reward":0.5,"step_verifier":{"score":0.5},"error":null,"timestamp":"2026-10-01T12:00:01Z","duration_ms":40.0,"exit_code":0},{"index":2,"action":{"action_type":"write_file","content":"x","events":[],"x":null,"y":null,"to_x":null,"to_y":null,"path":"notes/extra.txt","amount":1,"tool":null,"arguments":{}},"messages":[{"role":"tool","content":"exit 0","token_ids":null,"logprobs":null},{"role":"assistant","content":"write notes/extra.txt","token_ids":null,"logprobs":null}],"observation":null,"applied":false,"reward":null,"step_verifier":null,"error":"ActionError: Action 'write_file' not allowed here.","timestamp":"2026-10-01T12:00:02Z","duration_ms":1.0,"exit_code":null},{"index":3,"action":{"action_type":"done","content":"","events":[],"x":null,"y":null,"to_x":null,"to_y":null,"path":null,"amount":1,"tool":null,"arguments":{}},"messages":[{"role":"user","content":"write_file is not allowed.","token_ids":null,"logprobs":null},{"role":"assistant","content":"done","token_ids":null,"logprobs":null}],"observation":{"content":"","step":2,"done":true,"screenshot":null,"metadata":null},"applied":true,"reward":0.5,"step_verifier":{"score":0.5},"error":null,"timestamp":"2026-10-01T12:00:03Z","duration_ms":30.0,"exit_code":null}],"result":{"score":1.0,"passed":true,"output":"notes/out.txt says hello","stderr":"","exit_code":0,"execution_time_ms":12.5,"sandbox_id":"local-session","reward_components":null,"metrics":null,"seed":null,"truncated":false,"error_type":null,"cases":null,"isolation":null},"prior_results":[],"error":null,"finished_at":"2026-10-01T12:00:04Z","turns":[[1],[2],[3]],"status":"complete","has_verdict":true} {"schema_version":"1.1","trace_id":"trace0002","task":"Write hello to notes/out.txt, then finish.","provenance":{"env":{"name":"write_notes","variant":2,"seed":7,"environment_sha256":"7f142979bcdf5748ac3baa872a5db2a58d701da9bb29b7451d747e8a3777b6f9","task_sha256":"1bbf96220c257b1281f09efdf074dc9cb437d3f4401d77c008308bfd72fccc28","verifier_sha256":"5d6ee51bb39cafa8308963062208847333565120aa1e94980872375cd011d158","step_verifier_sha256":"e4a424cca79d7c48e6fd4bc2827e14523db51284f401d9c0321d43117878860b","reference_sha256":"a8bad614127cbaaaaa67e14952ae0fc6c0dce71bdff0822d038556b7dfbe3431","image":"python:3.12-slim@sha256:abababababababababababababababababababababababababababababababab","image_digest":"sha256:abababababababababababababababababababababababababababababababab","digest_unavailable":null,"snapshot":null},"agent":{"name":"react-shell","version":"1.2.0","model":"Qwen/Qwen3-8B","sampling":{"temperature":0.7,"top_p":0.95}},"provider":"local","deepgym_version":"0.4.1","deepgym_commit":null,"deepgym_dirty":null,"started_at":"2026-10-01T12:00:00Z"},"initial_observation":{"content":"$ ","step":0,"done":false,"screenshot":"iVBORw0KGgogc2FtcGxlIHNjcmVlbnNob3Q=","metadata":null},"steps":[{"index":1,"action":{"action_type":"bash","content":"mkdir -p notes && echo hello > notes/out.txt","events":[],"x":null,"y":null,"to_x":null,"to_y":null,"path":null,"amount":1,"tool":null,"arguments":{}},"messages":[],"observation":{"content":"","step":1,"done":false,"screenshot":null,"metadata":{"exit_code":0}},"applied":true,"reward":0.5,"step_verifier":{"score":0.5},"error":null,"timestamp":"2026-10-01T12:00:01Z","duration_ms":40.0,"exit_code":0}],"result":null,"prior_results":[],"error":"SandboxError: sandbox vanished","finished_at":null,"turns":[[1]],"status":"incomplete","has_verdict":false} -{"schema_version":"1.1","trace_id":"trace0003","task":"Write hello to notes/out.txt, then finish.","provenance":{"env":{"name":"write_notes","variant":2,"seed":7,"environment_sha256":"7f142979bcdf5748ac3baa872a5db2a58d701da9bb29b7451d747e8a3777b6f9","task_sha256":"1bbf96220c257b1281f09efdf074dc9cb437d3f4401d77c008308bfd72fccc28","verifier_sha256":"5d6ee51bb39cafa8308963062208847333565120aa1e94980872375cd011d158","step_verifier_sha256":"e4a424cca79d7c48e6fd4bc2827e14523db51284f401d9c0321d43117878860b","reference_sha256":"a8bad614127cbaaaaa67e14952ae0fc6c0dce71bdff0822d038556b7dfbe3431","image":"python:3.12-slim@sha256:abababababababababababababababababababababababababababababababab","image_digest":"sha256:abababababababababababababababababababababababababababababababab","digest_unavailable":null,"snapshot":null},"agent":{"name":"react-shell","version":"1.2.0","model":"Qwen/Qwen3-8B","sampling":{"temperature":0.7,"top_p":0.95}},"provider":"local","deepgym_version":"0.4.1","deepgym_commit":null,"deepgym_dirty":null,"started_at":"2026-10-01T12:00:00Z"},"initial_observation":{"content":"$ ","step":0,"done":false,"screenshot":"iVBORw0KGgogc2FtcGxlIHNjcmVlbnNob3Q=","metadata":null},"steps":[{"index":1,"action":{"action_type":"bash","content":"mkdir -p notes && echo hello > notes/out.txt","events":[],"x":null,"y":null,"to_x":null,"to_y":null,"path":null,"amount":1,"tool":null,"arguments":{}},"messages":[{"role":"system","content":"You are a shell agent.","token_ids":null,"logprobs":null},{"role":"user","content":"Write hello to notes/out.txt, then finish.\n$ ","token_ids":null,"logprobs":null},{"role":"assistant","content":"mkdir -p notes && echo hello > notes/out.txt","token_ids":[11,12,13],"logprobs":[-0.25,-0.5,-0.125]}],"observation":{"content":"","step":1,"done":false,"screenshot":null,"metadata":{"exit_code":0}},"applied":true,"reward":0.5,"step_verifier":{"score":0.5},"error":null,"timestamp":"2026-10-01T12:00:01Z","duration_ms":40.0,"exit_code":0},{"index":2,"action":{"action_type":"write_file","content":"x","events":[],"x":null,"y":null,"to_x":null,"to_y":null,"path":"notes/extra.txt","amount":1,"tool":null,"arguments":{}},"messages":[{"role":"tool","content":"exit 0","token_ids":null,"logprobs":null},{"role":"assistant","content":"write notes/extra.txt","token_ids":null,"logprobs":null}],"observation":null,"applied":false,"reward":null,"step_verifier":null,"error":"ActionError: Action 'write_file' not allowed here.","timestamp":"2026-10-01T12:00:02Z","duration_ms":1.0,"exit_code":null},{"index":3,"action":{"action_type":"done","content":"","events":[],"x":null,"y":null,"to_x":null,"to_y":null,"path":null,"amount":1,"tool":null,"arguments":{}},"messages":[{"role":"user","content":"write_file is not allowed.","token_ids":null,"logprobs":null},{"role":"assistant","content":"done","token_ids":null,"logprobs":null}],"observation":{"content":"","step":2,"done":true,"screenshot":null,"metadata":null},"applied":true,"reward":0.5,"step_verifier":{"score":0.5},"error":null,"timestamp":"2026-10-01T12:00:03Z","duration_ms":30.0,"exit_code":null}],"result":{"score":0.0,"passed":false,"output":"notes/out.txt says hello","stderr":"","exit_code":2,"execution_time_ms":12.5,"sandbox_id":"local-session","reward_components":null,"metrics":null,"seed":null,"truncated":false,"error_type":"sandbox_error","cases":null},"prior_results":[],"error":null,"finished_at":"2026-10-01T12:00:04Z","turns":[[1],[2],[3]],"status":"complete","has_verdict":false} +{"schema_version":"1.1","trace_id":"trace0003","task":"Write hello to notes/out.txt, then finish.","provenance":{"env":{"name":"write_notes","variant":2,"seed":7,"environment_sha256":"7f142979bcdf5748ac3baa872a5db2a58d701da9bb29b7451d747e8a3777b6f9","task_sha256":"1bbf96220c257b1281f09efdf074dc9cb437d3f4401d77c008308bfd72fccc28","verifier_sha256":"5d6ee51bb39cafa8308963062208847333565120aa1e94980872375cd011d158","step_verifier_sha256":"e4a424cca79d7c48e6fd4bc2827e14523db51284f401d9c0321d43117878860b","reference_sha256":"a8bad614127cbaaaaa67e14952ae0fc6c0dce71bdff0822d038556b7dfbe3431","image":"python:3.12-slim@sha256:abababababababababababababababababababababababababababababababab","image_digest":"sha256:abababababababababababababababababababababababababababababababab","digest_unavailable":null,"snapshot":null},"agent":{"name":"react-shell","version":"1.2.0","model":"Qwen/Qwen3-8B","sampling":{"temperature":0.7,"top_p":0.95}},"provider":"local","deepgym_version":"0.4.1","deepgym_commit":null,"deepgym_dirty":null,"started_at":"2026-10-01T12:00:00Z"},"initial_observation":{"content":"$ ","step":0,"done":false,"screenshot":"iVBORw0KGgogc2FtcGxlIHNjcmVlbnNob3Q=","metadata":null},"steps":[{"index":1,"action":{"action_type":"bash","content":"mkdir -p notes && echo hello > notes/out.txt","events":[],"x":null,"y":null,"to_x":null,"to_y":null,"path":null,"amount":1,"tool":null,"arguments":{}},"messages":[{"role":"system","content":"You are a shell agent.","token_ids":null,"logprobs":null},{"role":"user","content":"Write hello to notes/out.txt, then finish.\n$ ","token_ids":null,"logprobs":null},{"role":"assistant","content":"mkdir -p notes && echo hello > notes/out.txt","token_ids":[11,12,13],"logprobs":[-0.25,-0.5,-0.125]}],"observation":{"content":"","step":1,"done":false,"screenshot":null,"metadata":{"exit_code":0}},"applied":true,"reward":0.5,"step_verifier":{"score":0.5},"error":null,"timestamp":"2026-10-01T12:00:01Z","duration_ms":40.0,"exit_code":0},{"index":2,"action":{"action_type":"write_file","content":"x","events":[],"x":null,"y":null,"to_x":null,"to_y":null,"path":"notes/extra.txt","amount":1,"tool":null,"arguments":{}},"messages":[{"role":"tool","content":"exit 0","token_ids":null,"logprobs":null},{"role":"assistant","content":"write notes/extra.txt","token_ids":null,"logprobs":null}],"observation":null,"applied":false,"reward":null,"step_verifier":null,"error":"ActionError: Action 'write_file' not allowed here.","timestamp":"2026-10-01T12:00:02Z","duration_ms":1.0,"exit_code":null},{"index":3,"action":{"action_type":"done","content":"","events":[],"x":null,"y":null,"to_x":null,"to_y":null,"path":null,"amount":1,"tool":null,"arguments":{}},"messages":[{"role":"user","content":"write_file is not allowed.","token_ids":null,"logprobs":null},{"role":"assistant","content":"done","token_ids":null,"logprobs":null}],"observation":{"content":"","step":2,"done":true,"screenshot":null,"metadata":null},"applied":true,"reward":0.5,"step_verifier":{"score":0.5},"error":null,"timestamp":"2026-10-01T12:00:03Z","duration_ms":30.0,"exit_code":null}],"result":{"score":0.0,"passed":false,"output":"notes/out.txt says hello","stderr":"","exit_code":2,"execution_time_ms":12.5,"sandbox_id":"local-session","reward_components":null,"metrics":null,"seed":null,"truncated":false,"error_type":"sandbox_error","cases":null,"isolation":null},"prior_results":[],"error":null,"finished_at":"2026-10-01T12:00:04Z","turns":[[1],[2],[3]],"status":"complete","has_verdict":false} diff --git a/deepgym/tests/test_adversarial.py b/deepgym/tests/test_adversarial.py index 7c6d327b..d3a79fe9 100644 --- a/deepgym/tests/test_adversarial.py +++ b/deepgym/tests/test_adversarial.py @@ -1,9 +1,12 @@ """Tests for deepgym.adversarial — reward hack detection.""" +import contextlib import json import subprocess import sys import tempfile +import types +from collections.abc import Iterator from pathlib import Path import pytest @@ -16,6 +19,7 @@ entry_points, ) from deepgym.core import DeepGym +from deepgym.exceptions import DeepGymError from deepgym.models import Environment @@ -326,17 +330,62 @@ def test_payloads_carry_json_values_as_python(strategy: str, printed: list[str]) class _ScriptedLLM: - """Stands in for the Anthropic client: answers each round from a list.""" + """Stands in for the Anthropic client: streams each round's reply from a list.""" - def __init__(self, replies: list[str]) -> None: + def __init__(self, replies: list[str | Exception], stop: str = 'end_turn') -> None: self.replies = iter(replies) self.messages = self + self.stop = stop + self.max_tokens: list[object] = [] - def create(self, **kwargs: object) -> object: + @contextlib.contextmanager + def stream(self, **kwargs: object) -> Iterator[object]: + self.max_tokens.append(kwargs['max_tokens']) reply = next(self.replies) if isinstance(reply, Exception): raise reply - return type('Message', (), {'content': [type('Text', (), {'text': reply})()]})() + text = types.SimpleNamespace(type='text', text=reply) + message = types.SimpleNamespace(content=[text], stop_reason=self.stop) + events = ['message_start', 'content_block_delta'] + events += [] if self.stop == 'cut' else ['message_stop'] + yield _Stream([types.SimpleNamespace(type=kind) for kind in events], message) + + +class _Stream(list): + """A finished (or cut) SDK message stream: its events, then the message they built.""" + + def __init__(self, events: list[object], message: object) -> None: + super().__init__(events) + self.get_final_message = lambda: message + + +def test_claude_is_asked_for_its_maximum_and_a_smaller_one_is_read_from_its_error() -> None: + """No output cap of ours: Opus 5.5's maximum first, then the one a smaller model states.""" + from deepgym.adversarial import claude_reply + + stated = RuntimeError( + 'max_tokens: 128000 > 64000, which is the maximum allowed number of output tokens' + ) + llm = _ScriptedLLM([stated, 'the whole reply']) + + assert claude_reply(llm, 'claude-haiku', 'p') == 'the whole reply' + assert llm.max_tokens == [128_000, 64_000] + + +def test_a_reply_cut_off_at_the_output_limit_is_refused() -> None: + from deepgym.adversarial import claude_reply + + with pytest.raises(DeepGymError, match='output limit'): + claude_reply(_ScriptedLLM(['half a list'], stop='max_tokens'), 'm', 'p') + + +def test_a_stream_that_ends_before_message_stop_certifies_nothing() -> None: + """A dropped stream leaves the SDK a partial message: one parseable attack of five + must make the audit inconclusive, never an apparently complete, robust result.""" + from deepgym.adversarial import claude_reply + + with pytest.raises(DeepGymError, match='ended before it completed'): + claude_reply(_ScriptedLLM(['---EXPLOIT 1: x---\nprint(1)'], stop='cut'), 'm', 'p') @pytest.mark.parametrize( diff --git a/deepgym/tests/test_api.py b/deepgym/tests/test_api.py index 149aaae3..5a527d56 100644 --- a/deepgym/tests/test_api.py +++ b/deepgym/tests/test_api.py @@ -25,7 +25,7 @@ from deepgym.core import DeepGym from deepgym.exceptions import SandboxError from deepgym.models import Environment, Job, JobStatus, RunResult -from deepgym.session import Session +from deepgym.session import LocalBackend, Session try: import tomllib @@ -449,6 +449,7 @@ def _local_dg() -> DeepGym: 'print(json.dumps({"score": float(ok), "passed": ok}))\n' ), } +BAD_SETUP_ENV = {**FILE_CHECK_ENV, 'setup_script': 'exit 1'} def _open_session(app_client: TestClient) -> str: @@ -458,18 +459,19 @@ def _open_session(app_client: TestClient) -> str: return created.json()['session_id'] -def _fail_first_close(monkeypatch: pytest.MonkeyPatch, session: Session) -> None: - """Make *session*'s first close fail like a provider outage; later ones really close.""" - real_close = session.close - calls: list[int] = [] +def _fail_deletes(monkeypatch: pytest.MonkeyPatch, failures: int = 1) -> None: + """Make the next *failures* sandbox deletes fail like a provider outage; later ones + really delete.""" + real_close = LocalBackend.close + failed: list[LocalBackend] = [] - def close() -> None: - calls.append(1) - if len(calls) == 1: + def close(backend: LocalBackend) -> None: + if len(failed) < failures: + failed.append(backend) raise SandboxError('Could not delete sandbox: provider unavailable') - real_close() + real_close(backend) - monkeypatch.setattr(session, 'close', close) + monkeypatch.setattr(LocalBackend, 'close', close) class TestSessions: @@ -628,7 +630,7 @@ def test_failed_delete_keeps_the_slot_until_a_retry_succeeds( """S2/H7: a failed delete keeps the session's id and slot; DELETE can be retried.""" monkeypatch.setattr(routes, '_session_slots', threading.BoundedSemaphore(1)) session_id = _open_session(client) - _fail_first_close(monkeypatch, routes._sessions[session_id]) + _fail_deletes(monkeypatch) assert client.delete(f'/v1/sessions/{session_id}').status_code == 502 assert client.post('/v1/sessions', json={'environment': FILE_CHECK_ENV}).status_code == 429 @@ -642,7 +644,7 @@ def test_sweep_retries_a_failed_close( """S1/S2: a close that fails during a sweep keeps its slot and is retried next sweep.""" monkeypatch.setattr(routes, '_session_slots', threading.BoundedSemaphore(1)) session_id = _open_session(client) - _fail_first_close(monkeypatch, routes._sessions[session_id]) + _fail_deletes(monkeypatch) routes._last_used[session_id] -= routes._SESSION_IDLE_TTL_S + 1 routes.sweep_sessions() @@ -680,6 +682,67 @@ def refuse() -> None: assert client.post('/v1/sessions', json={'environment': FILE_CHECK_ENV}).status_code == 201 real_close() + @pytest.mark.parametrize( + ('route', 'body'), + [('step', {'action_type': 'bash', 'content': 'echo hello > out.txt'}), ('score', None)], + ) + def test_a_request_that_loses_the_race_with_delete_is_refused( + self, client: TestClient, monkeypatch: pytest.MonkeyPatch, route: str, body: dict | None + ) -> None: + """A5: a request whose route looked the session up just before a DELETE closed it + never reaches the deleted sandbox, so the host's delete is not scored as the agent's + zero.""" + session_id = _open_session(client) + session = routes._sessions[session_id] + real_get_session = routes._get_session + + def get_then_delete(sid: str) -> Session: + found = real_get_session(sid) + routes.close_session(sid) # the DELETE lands between the lookup and the request + return found + + monkeypatch.setattr(routes, '_get_session', get_then_delete) + + response = client.post(f'/v1/sessions/{session_id}/{route}', json=body) + + assert response.status_code == 409 + assert 'closed' in response.json()['detail'] + assert not session.trajectory.steps and session.failed_result is None + + def test_a_failed_start_whose_delete_fails_is_retried_by_the_sweep( + self, client: TestClient, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A6: a sandbox whose setup and first delete both fail keeps its slot until the + sweep deletes it, like a failed DELETE.""" + monkeypatch.setattr(routes, '_session_slots', threading.BoundedSemaphore(1)) + _fail_deletes(monkeypatch) + + assert client.post('/v1/sessions', json={'environment': BAD_SETUP_ENV}).status_code == 502 + assert client.post('/v1/sessions', json={'environment': FILE_CHECK_ENV}).status_code == 429 + + routes.sweep_sessions() + + assert not routes._closing + assert client.post('/v1/sessions', json={'environment': FILE_CHECK_ENV}).status_code == 201 + + def test_a_failed_start_whose_delete_never_succeeds_is_reported_leaked( + self, client: TestClient, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A6: a sandbox whose setup failed and that can't be deleted is given up on like any + other: reported as leaked, with its slot freed.""" + monkeypatch.setattr(routes, '_session_slots', threading.BoundedSemaphore(1)) + monkeypatch.setattr(routes, '_MAX_CLOSE_ATTEMPTS', 2) + _fail_deletes(monkeypatch, failures=2) + + assert client.post('/v1/sessions', json={'environment': BAD_SETUP_ENV}).status_code == 502 + [session] = routes._closing.values() + routes.sweep_sessions() # the second failed attempt: given up + + assert list(routes._leaked_sandboxes) == [session.sandbox_id] + assert client.get('/health').json()['leaked_sandboxes'] == 1 + assert client.post('/v1/sessions', json={'environment': FILE_CHECK_ENV}).status_code == 201 + session.close() + def test_bad_variant_is_404(self, client: TestClient) -> None: resp = client.post('/v1/sessions', json={'environment': 'todo_list', 'variant': 99}) assert resp.status_code == 404 diff --git a/deepgym/tests/test_cli.py b/deepgym/tests/test_cli.py index b1fa33a6..df677217 100644 --- a/deepgym/tests/test_cli.py +++ b/deepgym/tests/test_cli.py @@ -464,7 +464,8 @@ def test_model_with_the_flag_proceeds( def handler(request: httpx.Request) -> httpx.Response: answer = '```python\ndef reverse_string(s):\n return s[::-1]\n```' - return httpx.Response(200, json={'choices': [{'message': {'content': answer}}]}) + choice = {'message': {'content': answer}, 'finish_reason': 'stop'} + return httpx.Response(200, json={'choices': [choice]}) client = ZaiChatClient('key', model='m', transport=httpx.MockTransport(handler)) monkeypatch.setattr('deepgym.qa.client_from_env', lambda model: client) diff --git a/deepgym/tests/test_console.py b/deepgym/tests/test_console.py index 880310c5..8ac97cd3 100644 --- a/deepgym/tests/test_console.py +++ b/deepgym/tests/test_console.py @@ -10,7 +10,7 @@ from deepgym.console.db import connect from deepgym.console.run_store import RunStore -from deepgym.console.schemas import RunSpec +from deepgym.console.schemas import RunSpec, RunSummary from deepgym.models import RunResult from deepgym.qa.report import QACheck, QAReport, write_report from deepgym.sandboxes.apple import AppleContainer @@ -37,6 +37,12 @@ def _wait_finished(client: TestClient, run_id: str, timeout_s: float = 60) -> di raise AssertionError(f'run {run_id} did not finish in {timeout_s}s') +def _queue(db: Path, environment: str, count: int) -> list[RunSummary]: + """Queue *count* runs of *environment* as one eval; return them newest first.""" + spec = RunSpec(environment=environment, solution='pass', reference=True) + return RunStore(db).create_group([spec] * count, 'local', 'local')[::-1] + + class TestEnvironments: def test_list_has_every_kind(self, client: TestClient) -> None: envs = client.get('/api/environments').json() @@ -114,10 +120,10 @@ def test_small_eval_shares_a_group(self, client: TestClient) -> None: for run in queued: _wait_finished(client, run['id']) - listed = client.get(f'/api/runs?group_id={queued[0]["group_id"]}').json() + listed = client.get(f'/api/runs?group_id={queued[0]["group_id"]}').json()['runs'] assert sorted(run['environment'] for run in listed) == ['fizzbuzz', 'reverse_string'] - assert client.get('/api/runs?status=completed').json() + assert client.get('/api/runs?status=completed').json()['total'] == 2 @pytest.mark.parametrize( ('body', 'code'), @@ -126,11 +132,39 @@ def test_small_eval_shares_a_group(self, client: TestClient) -> None: ) # fmt: skip def test_rejected_requests(self, client: TestClient, body: dict, code: int) -> None: assert client.post('/api/runs', json=body).status_code == code - assert client.get('/api/runs').json() == [] + assert client.get('/api/runs').json() == {'runs': [], 'total': 0} def test_unknown_run_is_404(self, client: TestClient) -> None: assert client.get('/api/runs/nope').status_code == 404 + def test_every_run_is_reachable_a_page_at_a_time(self, tmp_path: Path) -> None: + newest_first = [run.id for run in _queue(tmp_path / 'console.db', 'fizzbuzz', 201)] + client = make_client(tmp_path) + + everything = client.get('/api/runs').json() + first = client.get('/api/runs?limit=25').json() + last = client.get('/api/runs?offset=200&limit=25').json() + + assert everything['total'] == 201 + assert [r['id'] for r in everything['runs']] == newest_first + assert (first['total'], [r['id'] for r in first['runs']]) == (201, newest_first[:25]) + assert (last['total'], [r['id'] for r in last['runs']]) == (201, newest_first[200:]) + + def test_filters_apply_to_the_page_and_the_total(self, tmp_path: Path) -> None: + db = tmp_path / 'console.db' + _queue(db, 'fizzbuzz', 201) + evaluation = _queue(db, 'two_sum', 30) + two_sum = [run.id for run in evaluation] + client = make_client(tmp_path) # fails the queued runs, as left by a stopped console + + by_env = client.get('/api/runs?environment=two_sum&status=failed&offset=25').json() + by_group = client.get(f'/api/runs?group_id={evaluation[0].group_id}&limit=10').json() + none = client.get('/api/runs?environment=two_sum&status=completed&limit=10').json() + + assert (by_env['total'], [r['id'] for r in by_env['runs']]) == (30, two_sum[25:]) + assert (by_group['total'], [r['id'] for r in by_group['runs']]) == (30, two_sum[:10]) + assert none == {'runs': [], 'total': 0} + def test_restart_fails_unfinished_runs(self, tmp_path: Path) -> None: spec = RunSpec(environment='fizzbuzz', solution='pass', reference=False) (run,) = RunStore(tmp_path / 'console.db').create_group([spec], 'local', 'local') @@ -265,7 +299,7 @@ def test_custom_local_solution_is_refused(self, tmp_path: Path) -> None: assert response.status_code == 400 assert '--allow-local-exec' in response.json()['detail'] assert 'docker' in response.json()['detail'] - assert client.get('/api/runs').json() == [] + assert client.get('/api/runs').json() == {'runs': [], 'total': 0} def test_reference_still_runs_locally(self, tmp_path: Path) -> None: client = make_client(tmp_path) diff --git a/deepgym/tests/test_console_episodes.py b/deepgym/tests/test_console_episodes.py index c803a380..d93fb4f3 100644 --- a/deepgym/tests/test_console_episodes.py +++ b/deepgym/tests/test_console_episodes.py @@ -1,12 +1,15 @@ """Tests for recording and replaying episodes in the console.""" import base64 +import sqlite3 from pathlib import Path import pytest from fastapi.testclient import TestClient from deepgym.console import record_episode +from deepgym.console.episode_store import EpisodeStore +from deepgym.console.schemas import EpisodeCreate from deepgym.models import Action, Observation, RunResult, Trajectory from tests.console_client import make_client @@ -29,11 +32,24 @@ def _trajectory() -> Trajectory: ) -def _result() -> RunResult: - return RunResult( - score=1.0, passed=True, output='3/3', stderr='', exit_code=0, - execution_time_ms=5.0, sandbox_id='sb-1', - ) # fmt: skip +def _result(**changes: object) -> RunResult: + fields = { + 'score': 1.0, 'passed': True, 'output': '3/3', 'stderr': '', 'exit_code': 0, + 'execution_time_ms': 5.0, 'sandbox_id': 'sb-1', + } # fmt: skip + return RunResult.model_validate({**fields, **changes}) + + +# A provider or verifier that failed gave no verdict; a 0 the policy earned, a model fault +# included, is one. +PROVIDER_ERROR = _result( + score=0.0, passed=False, output='provider unreachable', exit_code=1, error_type='provider_error' +) +VERIFIER_CRASH = _result(score=0.0, passed=False, output='Traceback', exit_code=2) +SCORED_ZERO = _result(score=0.0, passed=False, output='0/3', exit_code=1) +MODEL_FAULT = _result( + score=0.0, passed=False, output='timed out', exit_code=1, error_type='solution_timeout' +) @pytest.fixture() @@ -87,7 +103,7 @@ def test_bad_screenshot_is_rejected(client: TestClient) -> None: response = client.post('/api/episodes', json={'trajectory': trajectory}) assert response.status_code == 422 - assert client.get('/api/episodes').json() == [] + assert client.get('/api/episodes').json() == {'episodes': [], 'total': 0} def test_record_episode_helper(client: TestClient, db: Path) -> None: @@ -95,9 +111,83 @@ def test_record_episode_helper(client: TestClient, db: Path) -> None: listed = client.get('/api/episodes?environment=todo_list').json() - assert [e['id'] for e in listed] == [episode_id] - assert listed[0]['score'] == 1.0 - assert client.get('/api/episodes?environment=other').json() == [] + assert [e['id'] for e in listed['episodes']] == [episode_id] + assert (listed['total'], listed['episodes'][0]['score']) == (1, 1.0) + assert client.get('/api/episodes?environment=other').json() == {'episodes': [], 'total': 0} + + +@pytest.mark.parametrize( + ('result', 'verdict'), + [ + (PROVIDER_ERROR, (None, None, False, 'provider_error')), + (VERIFIER_CRASH, (None, None, False, 'verifier_error')), + (SCORED_ZERO, (0.0, False, True, None)), + (MODEL_FAULT, (0.0, False, True, 'solution_timeout')), + (None, (None, None, None, None)), + ], + ids=['provider_error', 'verifier_crash', 'scored_zero', 'model_fault', 'unscored'], +) +def test_episode_without_a_verdict_is_not_a_scored_zero( + client: TestClient, + db: Path, + result: RunResult | None, + verdict: tuple[float | None, bool | None, bool | None, str | None], +) -> None: + record_episode(_trajectory(), result, environment='todo_list', db_path=db) + + (listed,) = client.get('/api/episodes').json()['episodes'] + detail = client.get(f'/api/episodes/{listed["id"]}').json() + + for episode in (listed, detail): + fields = ( + episode['score'], + episode['passed'], + episode['has_verdict'], + episode['error_type'], + ) + assert fields == verdict + assert detail['result'] == (None if result is None else result.model_dump(mode='json')) + + +def test_old_database_gains_verdicts_from_stored_results(tmp_path: Path, db: Path) -> None: + with sqlite3.connect(db) as conn: + conn.execute( + 'CREATE TABLE episodes (id TEXT PRIMARY KEY, environment TEXT NOT NULL, ' + 'steps INTEGER NOT NULL, total_reward REAL NOT NULL, score REAL, passed INTEGER, ' + 'frames TEXT NOT NULL, result TEXT, created_at TEXT NOT NULL, trace TEXT)' + ) + conn.executemany( + "INSERT INTO episodes VALUES (?, 'e', 0, 0, ?, ?, '[]', ?, ?, NULL)", + [ + ('crashed', 0.0, False, PROVIDER_ERROR.model_dump_json(), 't3'), + ('zero', 0.0, False, SCORED_ZERO.model_dump_json(), 't2'), + ('unscored', None, None, None, 't1'), + ], + ) + conn.close() + + listed = make_client(tmp_path).get('/api/episodes').json()['episodes'] + + assert [(e['id'], e['score'], e['has_verdict'], e['error_type']) for e in listed] == [ + ('crashed', None, False, 'provider_error'), + ('zero', 0.0, True, None), + ('unscored', None, None, None), + ] + + +def test_every_episode_is_reachable_a_page_at_a_time(client: TestClient, db: Path) -> None: + store = EpisodeStore(db) + episode = EpisodeCreate(environment='todo_list', trajectory=Trajectory()) + newest_first = [store.add(episode).id for _ in range(201)][::-1] + + everything = client.get('/api/episodes').json() + first = client.get('/api/episodes?limit=25').json() + last = client.get('/api/episodes?offset=200&limit=25').json() + + assert everything['total'] == 201 + assert [e['id'] for e in everything['episodes']] == newest_first + assert (first['total'], [e['id'] for e in first['episodes']]) == (201, newest_first[:25]) + assert (last['total'], [e['id'] for e in last['episodes']]) == (201, newest_first[200:]) def test_unknown_episode_is_404(client: TestClient) -> None: diff --git a/deepgym/tests/test_console_security.py b/deepgym/tests/test_console_security.py index bc3271b5..979d33d3 100644 --- a/deepgym/tests/test_console_security.py +++ b/deepgym/tests/test_console_security.py @@ -43,7 +43,7 @@ def test_cross_origin_writes_are_refused(tmp_path: Path) -> None: ).status_code == 403 ) - assert client.get('/api/runs').json() == [] + assert client.get('/api/runs').json() == {'runs': [], 'total': 0} def test_same_origin_writes_pass(tmp_path: Path) -> None: diff --git a/deepgym/tests/test_harbor.py b/deepgym/tests/test_harbor.py index 03f3fed4..7ed9f5f1 100644 --- a/deepgym/tests/test_harbor.py +++ b/deepgym/tests/test_harbor.py @@ -4,6 +4,7 @@ real-container runs in test_harbor_live.py. """ +import re import shutil import subprocess import sys @@ -46,6 +47,17 @@ def _task_copy(tmp_path: Path, source: Path = HELLO, name: str | None = None) -> return target +def _git_repo(path: Path, *tasks: Path) -> Path: + """Commit a copy of each of *tasks* to a new git repository at *path*.""" + for task in tasks: + shutil.copytree(task, path / task.name) + git = ['git', '-C', str(path), '-c', 'user.name=t', '-c', 'user.email=t@example.com'] + subprocess.run(['git', 'init', '-q', str(path)], check=True) + subprocess.run([*git, 'add', '.'], check=True) + subprocess.run([*git, 'commit', '-q', '-m', 'tasks'], check=True) + return path + + # --------------------------------------------------------------------------- # Task folders # --------------------------------------------------------------------------- @@ -256,12 +268,7 @@ def test_dataset_root_rejects_unknown_sources(source: str) -> None: def test_dataset_root_clones_a_git_repository_once( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - repo = tmp_path / 'repo' - shutil.copytree(TASKS, repo) - git = ['git', '-C', str(repo), '-c', 'user.name=t', '-c', 'user.email=t@example.com'] - subprocess.run(['git', 'init', '-q', str(repo)], check=True) - subprocess.run([*git, 'add', '.'], check=True) - subprocess.run([*git, 'commit', '-q', '-m', 'tasks'], check=True) + repo = _git_repo(tmp_path / 'repo', HELLO, BUILT) monkeypatch.setattr('deepgym.harbor.loader._cache_dir', lambda: tmp_path / 'cache') root = dataset_root(f'file://{repo}') @@ -271,6 +278,41 @@ def test_dataset_root_clones_a_git_repository_once( assert [task.name for task in load_tasks(f'file://{repo}')] == ['hello', 'hello-built'] +def test_dataset_root_gives_urls_with_one_readable_name_their_own_clones( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # Both read _team_one_task once separators become underscores. + one = _git_repo(tmp_path / 'team_one' / 'task', HELLO) + other = _git_repo(tmp_path / 'team' / 'one_task', BUILT) + monkeypatch.setattr('deepgym.harbor.loader._cache_dir', lambda: tmp_path / 'cache') + + assert [task.name for task in load_tasks(f'file://{one}')] == ['hello'] + assert [task.name for task in load_tasks(f'file://{other}')] == ['hello-built'] + assert dataset_root(f'file://{one}') != dataset_root(f'file://{other}') + + +def test_dataset_root_never_reuses_a_folder_git_does_not_tie_to_the_url( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + url = f'file://{_git_repo(tmp_path / "repo", HELLO)}' + cache = tmp_path / 'cache' + monkeypatch.setattr('deepgym.harbor.loader._cache_dir', lambda: cache) + legacy = cache / re.sub(r'[^\w.-]+', '_', url.split('://')[-1]) # the old, unkeyed name + shutil.copytree(BUILT, legacy / BUILT.name) + + root = dataset_root(url) + assert root != legacy + assert [task.name for task in load_tasks(url)] == ['hello'] + + set_url = ['git', '-C', str(root), 'remote', 'set-url', 'origin', 'file:///other'] + subprocess.run(set_url, check=True) + with pytest.raises(DeepGymError, match='is not a clone of'): + dataset_root(url) + shutil.rmtree(root / '.git') + with pytest.raises(DeepGymError, match='is not a clone of'): + dataset_root(url) + + # --------------------------------------------------------------------------- # Rewards # --------------------------------------------------------------------------- diff --git a/deepgym/tests/test_harbor_isolation.py b/deepgym/tests/test_harbor_isolation.py index fd8acded..3d33a631 100644 --- a/deepgym/tests/test_harbor_isolation.py +++ b/deepgym/tests/test_harbor_isolation.py @@ -222,6 +222,15 @@ def test_grader_restores_then_uploads_tests_then_reads_the_reward_separately() - assert box.deleted +def test_grader_keeps_every_line_the_tests_print() -> None: + output = ''.join(f'test {n} passed\n' for n in range(5000)) + box = _box(_PASS, (_RUN_TESTS, output)) + + result = grade(box, HarborTask.from_dir(HELLO), _WORKSPACE, '/app') + + assert result.stderr == output + + def test_grader_without_a_reward_is_a_verifier_error_and_still_cleans_up() -> None: box = _box(('for name in reward.json', 'none')) with pytest.raises(VerifierError, match='no reward'): diff --git a/deepgym/tests/test_integrations.py b/deepgym/tests/test_integrations.py index 58cc8910..0cd6ffee 100644 --- a/deepgym/tests/test_integrations.py +++ b/deepgym/tests/test_integrations.py @@ -723,7 +723,7 @@ def test_a_failure_other_than_the_providers_is_never_graded_again( @pytest.mark.parametrize('infra_failure_score', ['zero', 'nan']) @pytest.mark.parametrize( ('second', 'budget'), - [(ConnectionError('still unavailable'), 150.0), (2.0, 0.5)], + [(ConnectionError('still unavailable'), 150.0), (4.0, 2.0)], ids=['fails_too', 'out_of_time'], ) def test_a_second_grading_that_fails_gets_the_infrastructure_score( @@ -734,7 +734,8 @@ def test_a_second_grading_that_fails_gets_the_infrastructure_score( second: BaseException | float, budget: float, ) -> None: - """Failing again, or passing too late (2 s against 0.5 s), is never a pass or an error. + """Failing again, or passing too late (4 s against 2 s, so the retry surely starts + in time on a busy runner), is never a pass or an error. 0 in zero mode; NaN in nan mode, which OpenRLHF's dynamic filtering drops. """ diff --git a/deepgym/tests/test_integrations_lm_eval.py b/deepgym/tests/test_integrations_lm_eval.py index 8b440073..23d8eced 100644 --- a/deepgym/tests/test_integrations_lm_eval.py +++ b/deepgym/tests/test_integrations_lm_eval.py @@ -40,10 +40,12 @@ def lm_eval() -> ModuleType: def test_raises_import_error_when_lm_eval_missing(env: Environment) -> None: with patch('builtins.__import__', side_effect=ImportError('no lm_eval')): with pytest.raises(ImportError, match='lm-evaluation-harness'): - make_lm_eval_task(env, task_name='reverse_string') + make_lm_eval_task(env, task_name='reverse_string', max_gen_toks=8192) -def _evaluate(lm_eval: ModuleType, reply: str, *tasks: object) -> tuple[dict, list]: +def _evaluate( + lm_eval: ModuleType, reply: str, *tasks: object, gen_kwargs: dict | None = None +) -> tuple[dict, list]: """Run *tasks* through lm_eval.simple_evaluate with a model that always says *reply*.""" from lm_eval.api.model import LM from lm_eval.tasks import TaskManager @@ -67,7 +69,11 @@ def loglikelihood_rolling(self, requests: list, disable_tqdm: bool = False) -> l # Our tasks are objects; skip indexing lm-eval's thousands of built-in task configs. task_manager = TaskManager(include_defaults=False) out = lm_eval.simple_evaluate( - model=model, tasks=list(tasks), task_manager=task_manager, bootstrap_iters=0 + model=model, + tasks=list(tasks), + task_manager=task_manager, + bootstrap_iters=0, + gen_kwargs=gen_kwargs, ) return out['results'], model.requests @@ -79,7 +85,7 @@ def test_fenced_answer_is_scored_by_the_verifier( results, requests = _evaluate( lm_eval, f'Sure:\n```python\n{GOOD_SOLUTION}```', - make_lm_eval_task(env, 'reverse_string', local_dg), + make_lm_eval_task(env, 'reverse_string', local_dg, max_gen_toks=8192), ) metrics = results['deepgym_reverse_string'] @@ -92,6 +98,20 @@ def test_fenced_answer_is_scored_by_the_verifier( assert generation_kwargs['until'] == [] +def test_generation_runs_to_the_maximum_the_caller_states( + lm_eval: ModuleType, env: Environment, local_dg: DeepGym +) -> None: + """No length of ours or lm-eval's 256 default: the model's maximum, stated by the + caller, reaches the model, and there is no default to fall back on.""" + task = make_lm_eval_task(env, 'r', local_dg, max_gen_toks=32768) + + _, requests = _evaluate(lm_eval, GOOD_SOLUTION, task) + + assert requests[0].args[1]['max_gen_toks'] == 32768 + with pytest.raises(TypeError, match='max_gen_toks'): + make_lm_eval_task(env, 'r', local_dg) # type: ignore[call-arg] + + def test_a_verifier_failure_is_not_a_wrong_answer( lm_eval: ModuleType, env: Environment, local_dg: DeepGym ) -> None: @@ -100,8 +120,8 @@ def test_a_verifier_failure_is_not_a_wrong_answer( results, _ = _evaluate( lm_eval, GOOD_SOLUTION, - make_lm_eval_task(env, 'reverse_string', local_dg), - make_lm_eval_task(broken, 'broken', local_dg), + make_lm_eval_task(env, 'reverse_string', local_dg, max_gen_toks=8192), + make_lm_eval_task(broken, 'broken', local_dg, max_gen_toks=8192), ) assert results['deepgym_reverse_string']['deepgym_score,none'] == 1.0 @@ -114,7 +134,9 @@ def test_a_verifier_failure_is_not_a_wrong_answer( def test_a_wrong_answer_scores_zero_with_a_verdict( lm_eval: ModuleType, env: Environment, local_dg: DeepGym ) -> None: - results, _ = _evaluate(lm_eval, 'pass\n', make_lm_eval_task(env, 'reverse_string', local_dg)) + results, _ = _evaluate( + lm_eval, 'pass\n', make_lm_eval_task(env, 'reverse_string', local_dg, max_gen_toks=8192) + ) metrics = results['deepgym_reverse_string'] assert metrics['deepgym_score,none'] == 0.0 diff --git a/deepgym/tests/test_integrations_zai.py b/deepgym/tests/test_integrations_zai.py index 88e1ec36..8b207c6f 100644 --- a/deepgym/tests/test_integrations_zai.py +++ b/deepgym/tests/test_integrations_zai.py @@ -31,7 +31,7 @@ def handler(request: httpx.Request) -> httpx.Response: result = client.complete_prompt('solve task', thinking='disabled', temperature=0.0) - assert result == '{"ok": true}' + assert result.text == '{"ok": true}' assert seen['url'] == 'https://api.test/v4/chat/completions' assert seen['auth'] == 'Bearer test-key' assert seen['payload']['model'] == 'glm-5.1' @@ -55,5 +55,67 @@ def handler(request: httpx.Request) -> httpx.Response: client = ZaiChatClient('test-key', transport=httpx.MockTransport(handler)) - assert client.complete_prompt('solve task', thinking=None) == 'ok' + assert client.complete_prompt('solve task', thinking=None).text == 'ok' assert 'thinking' not in seen['payload'] + + +@pytest.mark.parametrize( + ('content', 'finish_reason', 'text', 'whole'), + [ + ('def f(): return 1', 'stop', 'def f(): return 1', True), + ('def f(): return 1', 'length', 'def f(): return 1', False), # same text, cut off + (None, 'length', '', False), # the whole output went to reasoning: no answer at all + ('def f(): return', 'content_filter', 'def f(): return', False), # withheld + ('def f(): return 1', None, 'def f(): return 1', None), # no reason: unknown + ('def f(): return 1', 'eos_mystery', 'def f(): return 1', None), # unknown reason + ], +) +def test_zai_client_sends_no_output_limit_and_returns_how_generation_ended( + content: str | None, finish_reason: str | None, text: str, whole: bool | None +) -> None: + seen = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen['payload'] = json.loads(request.content.decode()) + choice = {'message': {'content': content}, 'finish_reason': finish_reason} + return httpx.Response(200, json={'choices': [choice]}) + + client = ZaiChatClient('test-key', transport=httpx.MockTransport(handler)) + + completion = client.complete_prompt('solve task') + + assert 'max_tokens' not in seen['payload'] # the model's own maximum applies + assert (completion.text, completion.finish_reason) == (text, finish_reason) + assert completion.whole is whole + + +def test_zai_client_sends_an_output_limit_only_when_asked() -> None: + seen = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen['payload'] = json.loads(request.content.decode()) + return httpx.Response(200, json={'choices': [{'message': {'content': 'ok'}}]}) + + client = ZaiChatClient('test-key', transport=httpx.MockTransport(handler)) + + assert client.complete_prompt('solve task', max_tokens=50).finish_reason is None + assert seen['payload']['max_tokens'] == 50 + + +def test_a_cached_completion_keeps_its_finish_reason(tmp_path) -> None: + """A reply cut off at the output limit stays marked so from the cache; one cached as + text alone, before finish reasons were kept, comes back unknown, not whole.""" + from deepgym.integrations.zai import Completion, cached_completion + + cut = Completion(text='partial answer', finish_reason='length') + path = tmp_path / 'task.txt' + + assert cached_completion(path, lambda: cut) == (cut, 'model') + assert cached_completion(path, lambda: pytest.fail('cached')) == (cut, 'cache') + assert cached_completion(path, lambda: cut, refresh=True) == (cut, 'model') + + legacy_path = tmp_path / 'legacy.txt' + for text in ('an old cache: text only', '{"text": "injected", "finish_reason": "stop"}'): + legacy_path.write_text(text, encoding='utf-8') # an answer that looks like a record + legacy, source = cached_completion(legacy_path, lambda: pytest.fail('cached')) + assert (legacy.text, legacy.finish_reason, source) == (text, None, 'cache') diff --git a/deepgym/tests/test_isolation.py b/deepgym/tests/test_isolation.py index b6f066ef..b8aedee6 100644 --- a/deepgym/tests/test_isolation.py +++ b/deepgym/tests/test_isolation.py @@ -1408,3 +1408,27 @@ def test_a_python_alias_outside_the_installation_goes_by_its_target( monkeypatch.setattr(sys, 'executable', str(alias)) assert deepgym.judge.sandbox_python() == os.path.realpath(sys.executable) + + +@needs_sandbox +def test_a_local_result_records_how_its_solution_was_isolated( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """A stray DEEPGYM_ALLOW_UNSANDBOXED can't run a job unsandboxed without a trace: every + result says so, and the process warns once.""" + import logging + + import deepgym.judge + + env = load_environment('coin_change') + assert DeepGym(mode='local').run(env, WRONG).isolation == local_isolation_kind() + + for module in ('deepgym.judge', 'deepgym.sandbox'): + monkeypatch.setattr(f'{module}.local_isolation_kind', lambda: 'none') + monkeypatch.setenv(deepgym.judge.ALLOW_UNSANDBOXED_ENV, '1') + deepgym.judge._warn_unsandboxed.cache_clear() + with caplog.at_level(logging.WARNING, logger='deepgym.judge'): + results = [DeepGym(mode='local').run(env, WRONG) for _ in range(2)] + + assert [result.isolation for result in results] == ['none', 'none'] + assert sum('run unsandboxed' in record.message for record in caplog.records) == 1 diff --git a/deepgym/tests/test_local_executor.py b/deepgym/tests/test_local_executor.py index 11cee914..68cc7647 100644 --- a/deepgym/tests/test_local_executor.py +++ b/deepgym/tests/test_local_executor.py @@ -299,7 +299,7 @@ async def test_no_solution_process_outlives_its_run(tmp_path: Path, call: str, e ) if ending == 'cancel': run = asyncio.ensure_future(AsyncLocalExecutor().run(job)) - while not mark.exists(): + while not (mark.exists() and mark.read_text()): # created before it is written await asyncio.sleep(0.05) run.cancel() with pytest.raises(asyncio.CancelledError): @@ -308,6 +308,7 @@ async def test_no_solution_process_outlives_its_run(tmp_path: Path, call: str, e with contextlib.suppress(DeepGymError): # a crash or timeout has no verdict await asyncio.to_thread(LocalExecutor().run, job) solution_path = mark.read_text() + assert solution_path # an empty one would match, and kill, every process try: deadline = time.monotonic() + 5 while _processes_running(solution_path) and time.monotonic() < deadline: diff --git a/deepgym/tests/test_qa.py b/deepgym/tests/test_qa.py index a94bcccd..7f677e7b 100644 --- a/deepgym/tests/test_qa.py +++ b/deepgym/tests/test_qa.py @@ -303,12 +303,18 @@ def test_format_table_lists_failures_and_a_summary() -> None: _WRONG = 'Sure:\n```python\ndef add(a, b):\n return a - b\n```' -def _client(answers: list[str], seen: list[dict]) -> ZaiChatClient: - replies = itertools.cycle(answers) +def _client( + answers: list[str], seen: list[dict], finish_reasons: list[str] | None = None +) -> ZaiChatClient: + """A client whose endpoint gives *answers* in turn, each ended by its finish reason + ('stop' unless given).""" + replies = itertools.cycle(zip(answers, finish_reasons or ['stop'] * len(answers))) def handler(request: httpx.Request) -> httpx.Response: seen.append(json.loads(request.content.decode())) - return httpx.Response(200, json={'choices': [{'message': {'content': next(replies)}}]}) + content, finish_reason = next(replies) + choice = {'message': {'content': content}, 'finish_reason': finish_reason} + return httpx.Response(200, json={'choices': [choice]}) return ZaiChatClient( 'key', base_url='https://llm.test/v1', model='m', transport=httpx.MockTransport(handler) @@ -355,6 +361,32 @@ def test_difficulty_is_inconclusive_when_a_sample_gets_no_verdict(local_dg: Deep assert check.details == '1/2 samples got no verdict (the verifier errored)' +@pytest.mark.parametrize( + ('answers', 'finish_reasons', 'cut', 'reasons'), + [ + ([''], ['length'], 2, "['length']"), # all output went to reasoning: never too_hard + ([_RIGHT, '```python\ndef add(a, b):\n return a'], ['stop', 'length'], 1, "['length']"), + ([_RIGHT], ['content_filter'], 2, "['content_filter']"), # the endpoint withheld it + ([_RIGHT], [None], 2, "['None']"), # no reason given: unknown, not whole + ([_RIGHT], ['eos_mystery'], 2, "['eos_mystery']"), # a reason this client doesn't know + ], +) +def test_a_sample_that_is_not_a_whole_answer_is_not_a_failure( + answers: list[str], finish_reasons: list[str | None], cut: int, reasons: str, local_dg: DeepGym +) -> None: + seen: list[dict] = [] + client = _client(answers, seen, finish_reasons) + + check = difficulty_check(_env(_ADD_VERIFIER), local_dg, client, samples=2) + + assert check.status == 'inconclusive' # never too_hard, never a pass@1 of 0.50 + assert check.details == ( + f'{cut}/2 samples are not known to be whole answers (finish reasons {reasons}: cut ' + 'off, filtered or unknown)' + ) + assert all('max_tokens' not in request for request in seen) # the probe sets no limit + + def test_partial_rewards_are_variance_not_too_hard(local_dg: DeepGym) -> None: body = "return 0.2 if 'low' in open(solution_path).read() else 0.8\n" answers = ['```python\nlow = 1\n```', '```python\nhigh = 1\n```'] @@ -373,7 +405,8 @@ def handler(request: httpx.Request) -> httpx.Response: seen.append(status) if status != 200: return httpx.Response(status, headers={'retry-after': '0'}, json={}) - return httpx.Response(200, json={'choices': [{'message': {'content': _RIGHT}}]}) + choice = {'message': {'content': _RIGHT}, 'finish_reason': 'stop'} + return httpx.Response(200, json={'choices': [choice]}) transport = httpx.MockTransport(handler) return ZaiChatClient('key', base_url='https://llm.test/v1', model='m', transport=transport) @@ -451,7 +484,8 @@ def test_rate_limit_backoff_honors_retry_after_else_jitters( def handler(request: httpx.Request) -> httpx.Response: status, headers = next(replies) - body = {'choices': [{'message': {'content': _RIGHT}}]} if status == 200 else {} + choice = {'message': {'content': _RIGHT}, 'finish_reason': 'stop'} + body = {'choices': [choice]} if status == 200 else {} return httpx.Response(status, headers=headers, json=body) waits: list[float] = [] @@ -461,5 +495,5 @@ def handler(request: httpx.Request) -> httpx.Response: 'k', base_url='https://llm.test/v1', model='m', transport=httpx.MockTransport(handler) ) - assert difficulty._complete(client, 'task') == _RIGHT + assert difficulty._complete(client, 'task').text == _RIGHT assert waits == [2.0, 7.0, 8.0] # jitter up to 2s, then as asked, then up to 8s diff --git a/deepgym/tests/test_qa_mutants.py b/deepgym/tests/test_qa_mutants.py index 498d8a63..75c797bb 100644 --- a/deepgym/tests/test_qa_mutants.py +++ b/deepgym/tests/test_qa_mutants.py @@ -152,7 +152,7 @@ def test_a_weak_verifier_fails_the_mutation_check(tmp_path: Path, local_dg: Deep assert check.status == 'fail' assert 'survived: ' in check.details - assert 'line 6: dropped `if not values:`' in check.details # [1, 2, 3] never tests [] + assert 'line 6: dropped `if not values:\n return 0`' in check.details # never tests [] @needs_sandbox diff --git a/deepgym/tests/test_reward_qa.py b/deepgym/tests/test_reward_qa.py index 4caff64d..57d3c4eb 100644 --- a/deepgym/tests/test_reward_qa.py +++ b/deepgym/tests/test_reward_qa.py @@ -4,11 +4,12 @@ import pytest +from deepgym.adversarial import environment_id, grading_fingerprint from deepgym.benchmark_ops import build_benchmark_audit, load_environments_from_dir from deepgym.core import DeepGym from deepgym.exploit_db import ExploitDB from deepgym.models import Environment -from deepgym.reward_qa import RewardAuditor, fingerprint_verifier +from deepgym.reward_qa import RewardAuditor from tests.test_adversarial import CRASHING_VERIFIER, ROBUST_VERIFIER STANDALONE_PASSING_VERIFIER = ( @@ -43,7 +44,7 @@ def test_audit_detects_exploitable_verifier(self, dg: DeepGym) -> None: assert report.verifier_id == 'weak-verifier' assert report.exploitable is True assert report.risk_level in {'high', 'critical'} - assert report.verifier_hash == fingerprint_verifier(STANDALONE_PASSING_VERIFIER) + assert report.verifier_hash == grading_fingerprint(env) assert 'few-test-cases' in report.patterns assert report.recommendations assert report.stored is False @@ -175,3 +176,37 @@ def test_an_audit_that_judged_nothing_has_no_risk_rating(tmp_path: Path, dg: Dee assert report.status == 'inconclusive' assert (report.risk_score, report.risk_level) == (None, None) # never a reassuring LOW + + +def test_audit_ids_are_identity_and_hashes_cover_all_grading() -> None: + """Two tasks sharing a verifier keep their own ids and grade differently; a changed + verifier keeps its task's id, so its old stored result is found and dropped.""" + verifier = 'return 1.0\n' + first = Environment(name='first', task='t', verifier_code=verifier, test_cases=[{'x': 1}]) + second = first.model_copy(update={'name': 'second', 'test_cases': [{'x': 2}]}) + changed = first.model_copy(update={'verifier_code': verifier + '# edited\n'}) + prefix = 'x' * 80 + unnamed = [Environment(task=prefix + end, verifier_code=verifier) for end in ('a', 'b')] + + assert environment_id(first) != environment_id(second) + assert grading_fingerprint(first) != grading_fingerprint(second) + assert environment_id(changed) == environment_id(first) + assert grading_fingerprint(changed) != grading_fingerprint(first) + assert environment_id(unnamed[0]) != environment_id(unnamed[1]) # no 80-character prefix + + +def test_a_harbor_task_whose_tests_change_is_a_new_version() -> None: + """Harbor grades with its test scripts, not its placeholder verifier: changing them must + invalidate a stored result, or an old robust verdict outlives an unjudged retest.""" + from pathlib import Path + + from deepgym.harbor.environment import HarborEnvironment + + env = HarborEnvironment.from_dir(Path(__file__).parent / 'fixtures/harbor/tasks/hello') + tests = {name: body + b'\n# changed\n' for name, body in env.harbor_task.tests.items()} + changed = env.model_copy( + update={'harbor_task': env.harbor_task.model_copy(update={'tests': tests})} + ) + + assert environment_id(changed) == environment_id(env) + assert grading_fingerprint(changed) != grading_fingerprint(env) diff --git a/deepgym/tests/test_session.py b/deepgym/tests/test_session.py index 6f693afe..cf234ad1 100644 --- a/deepgym/tests/test_session.py +++ b/deepgym/tests/test_session.py @@ -5,6 +5,7 @@ import os import threading import time +from collections.abc import Callable from pathlib import Path import pytest @@ -12,6 +13,7 @@ from deepgym import AsyncDeepGym, ComputerUseEnvironment, DeepGym from deepgym import session as session_module +from deepgym.cli_runtime import CliRequest from deepgym.exceptions import ( ActionError, DeepGymError, @@ -34,6 +36,14 @@ 'print(json.dumps({"score": float(ok), "passed": ok}))\n' ) +CLI_REQUEST = CliRequest( + argv=('claude', '-p', 'task'), + env={'ANTHROPIC_API_KEY': 'test-key'}, + provider='anthropic', + timeout=5, + allow_model_credentials=True, +) + @pytest.fixture(scope='module') def dg() -> DeepGym: @@ -907,6 +917,31 @@ def test_double_start_raises(self, dg: DeepGym) -> None: with pytest.raises(DeepGymError, match='already started'): session.start() + @pytest.mark.parametrize( + 'operation', + [ + lambda session: session.start(), + lambda session: session.step(Action(action_type='bash', content='echo hi')), + lambda session: session.run_cli(CLI_REQUEST, lambda event: None), + lambda session: session.finalize_cli(), + lambda session: session.score(), + ], + ids=['start', 'step', 'run_cli', 'finalize_cli', 'score'], + ) + def test_a_closed_session_refuses_to_touch_its_sandbox( + self, dg: DeepGym, operation: Callable[[Session], object] + ) -> None: + """A5: a call queued behind close() would hit a sandbox that may be deleted and be + scored as the agent's failure; it is refused instead, and nothing is recorded.""" + session = dg.session(MultiTurnEnvironment(task='t', final_verifier_code=PASS_VERIFIER)) + session.start() + session.close() + + with pytest.raises(DeepGymError, match='closed'): + operation(session) + + assert not session.trajectory.steps and session.failed_result is None + def test_desktop_needs_daytona(self, dg: DeepGym) -> None: with dg.session(ComputerUseEnvironment(task='t')) as session: with pytest.raises(DeepGymError, match='Daytona'): @@ -967,3 +1002,17 @@ def test_an_explicit_close_still_raises( with pytest.raises(SandboxError, match='Could not delete'): session.close() + + +def test_a_local_session_result_records_its_unsandboxed_actions(dg: DeepGym) -> None: + """A local session runs the agent's actions as plain host processes: its results say + so, fixed ones too, whatever its verifier isolates.""" + env = MultiTurnEnvironment(task='t', max_steps=1, final_verifier_code=PASS_VERIFIER) + + _, graded = dg.run_episode(env, lambda obs: Action(action_type='done')) + with dg.session(env) as session: + session.start() + session.abandon('provider_error', 'lost') + fixed = session.score() + + assert graded.isolation == fixed.isolation == 'none'